From 0ad85f24d5c3bbfa905ab57b5cafd8e204b6caec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:05:26 -0400 Subject: [PATCH] Add support for downloading media Fixes #85 --- lib/helpers/extensions/save_attachment.dart | 34 ++++++++ lib/helpers/file_dialog_helper.dart | 93 +++++++++++++++++++++ lib/widgets/composer/composer.dart | 22 +++-- lib/widgets/expandable_image.dart | 26 +++++- lib/widgets/file_card.dart | 18 +++- lib/widgets/html/html.dart | 1 + lib/widgets/message_image.dart | 2 + lib/widgets/players/audio.dart | 22 ++++- lib/widgets/players/video.dart | 20 +++++ lib/widgets/renderers/message.dart | 55 ++++++++++-- lib/widgets/room_summary_sheet.dart | 1 + lib/widgets/space_hierarchy_sheet.dart | 2 + lib/widgets/user_bottom_sheet.dart | 1 + pubspec.lock | 10 ++- pubspec.yaml | 2 + 15 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 lib/helpers/extensions/save_attachment.dart create mode 100644 lib/helpers/file_dialog_helper.dart diff --git a/lib/helpers/extensions/save_attachment.dart b/lib/helpers/extensions/save_attachment.dart new file mode 100644 index 0000000..1fb048f --- /dev/null +++ b/lib/helpers/extensions/save_attachment.dart @@ -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 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()); + } + } +} diff --git a/lib/helpers/file_dialog_helper.dart b/lib/helpers/file_dialog_helper.dart new file mode 100644 index 0000000..de93d12 --- /dev/null +++ b/lib/helpers/file_dialog_helper.dart @@ -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 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 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 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.new); +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 82f0087..9b596c6 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -1,7 +1,6 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:file_selector/file_selector.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -13,6 +12,7 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/models/content/message.dart"; @@ -245,14 +245,24 @@ class const Composer( leading: Icon(icon), ), onTap: () => attach( - ref - .read(ImagePickerController.provider) - .pickImage(source: source), + source == .gallery + ? ref + .read(FileDialogHelper.provider) + .pickFile(images: true) + : ref + .read( + ImagePickerController + .provider, + ) + .pickImage(source: source), ).onError(showError), ), PopupMenuItem( - onTap: () => - attach(openFile()).onError(showError), + onTap: () => attach( + ref + .read(FileDialogHelper.provider) + .pickFile(), + ).onError(showError), child: ListTile( title: Text("Files"), leading: Icon(Icons.attachment), diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index 0639417..f10221e 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,6 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/widgets/error_dialog.dart"; @@ -8,6 +9,9 @@ import "package:nexus/widgets/error_dialog.dart"; final class const ExpandableImage( final DownloadMediaRequest? request, { required final Widget child, + + /// If set, a download button is shown, suggesting this filename + final String? downloadFilename, super.key, }) extends ConsumerWidget { @override @@ -38,9 +42,25 @@ final class const ExpandableImage( alignment: .topRight, child: Padding( padding: .all(32), - child: M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request!.mxc, + encrypted: request!.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], ), ), ), diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart index 4c0fdde..4c3ce43 100644 --- a/lib/widgets/file_card.dart +++ b/lib/widgets/file_card.dart @@ -1,4 +1,6 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/extensions/size_to_string.dart"; import "package:nexus/models/info/file.dart"; @@ -6,10 +8,11 @@ final class const FileCard( final Uri uri, final FileInfo? info, { final String? filename, + final bool encrypted = false, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) => SizedBox( + Widget build(BuildContext context, WidgetRef ref) => SizedBox( width: 320, child: Card( color: Theme.of(context).colorScheme.surfaceContainer, @@ -17,8 +20,15 @@ final class const FileCard( leading: Icon(Icons.file_copy), title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), - // TODO: Downloading files - trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + trailing: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + icon: Icon(Icons.download), + ), ), ), ); diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 325e449..5ea584f 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -79,6 +79,7 @@ class const Html( alignment: PlaceholderAlignment.middle, child: ExpandableImage( .new(mxc: src), + downloadFilename: src.pathSegments.last, child: Image( image: MxcImage(ref, .new(mxc: src)), errorBuilder: (_, error, _) => Text( diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index b04c209..42c08d6 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -11,6 +11,7 @@ final class const MessageImage( final Uri url, { final i.ImageInfo? info, required final bool encrypted, + final String? filename, super.key, }) extends ConsumerWidget { @override @@ -18,6 +19,7 @@ final class const MessageImage( final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); return ExpandableImage( request, + downloadFilename: filename, child: ClipRRect( borderRadius: .all(.circular(8)), child: AspectRatio( diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index cd69126..598687c 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -5,6 +5,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/audio.dart"; import "package:nexus/widgets/players/player_controls.dart"; @@ -12,6 +13,7 @@ class const AudioPlayer( final Uri uri, final AudioInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -38,8 +40,24 @@ class const AudioPlayer( child: Card( color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( - padding: .only(left: 8, right: 16), - child: PlayerControls(player, loading: !loaded.value), + padding: .symmetric(horizontal: 8), + child: PlayerControls( + player, + loading: !loaded.value, + actions: .new([ + SizedBox(width: 4), + IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ]), + ), ), ), ); diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 385602d..557e7c1 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -3,6 +3,7 @@ import "dart:async"; import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; @@ -13,6 +14,7 @@ class const VideoPlayer( final Uri uri, final VideoInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -61,6 +63,24 @@ class const VideoPlayer( onTap: loaded.value ? player.playOrPause : null, ), ), + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black38, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ), Positioned( left: 0, right: 0, diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 3404bc5..598e9f9 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -110,14 +110,53 @@ class const MessageRenderer( ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { - VideoMessageContent(:final info, :final file) => - VideoPlayer(url, info, encrypted: file != null), - AudioMessageContent(:final info, :final file) => - AudioPlayer(url, info, encrypted: file != null), - FileMessageContent(:final info, :final filename) => - FileCard(url, info, filename: filename), - ImageMessageContent(:final info, :final file) => - MessageImage(url, info: info, encrypted: file != null), + VideoMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + VideoPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + AudioMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + AudioPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + FileMessageContent( + :final info, + :final filename, + :final file, + ) => + FileCard( + url, + info, + filename: filename, + encrypted: file != null, + ), + ImageMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + MessageImage( + url, + info: info, + encrypted: file != null, + filename: filename ?? body, + ), _ => SizedBox.shrink(), }, ), diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index bfc14c7..60c5baa 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -30,6 +30,7 @@ class const RoomSummarySheet( if (summary.avatarUrl != null) ExpandableImage( summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + downloadFilename: name, child: AvatarOrHash( summary.avatarUrl, "", diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 7f8b588..85ae46c 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -236,6 +236,8 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) if (space?.avatarUrl case final avatarUrl?) ExpandableImage( .new(mxc: avatarUrl), + downloadFilename: + space?.name ?? space?.canonicalAlias ?? spaceId, child: AvatarOrHash(avatarUrl, "", dimension: 32), ), Expanded( diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 0232b08..b6da63d 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -103,6 +103,7 @@ final class const UserBottomSheet( ExpandableImage( member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, child: AvatarOrHash( member.avatarUrl, member.displayName ?? userId.localpart, diff --git a/pubspec.lock b/pubspec.lock index 6b755da..51443ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -455,6 +455,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_file_dialog: + dependency: "direct main" + description: + name: flutter_file_dialog + sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5" + url: "https://pub.dev" + source: hosted + version: "3.3.3" flutter_hooks: dependency: "direct main" description: @@ -985,7 +993,7 @@ packages: source: hosted version: "1.19.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 diff --git a/pubspec.yaml b/pubspec.yaml index 1c6fb4a..431a328 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,6 +107,8 @@ dependencies: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart ref: quad/nexus pasteboard: 0.5.0 + flutter_file_dialog: 3.3.3 + mime: 2.1.0 dev_dependencies: build_runner: 2.16.2