From fb8bab99767848cb5f54f3491ca157399bba682f Mon Sep 17 00:00:00 2001 From: chimnayajith Date: Thu, 12 Dec 2024 21:47:43 +0530 Subject: [PATCH] feat: add download button to lightbox to bottom app bar --- .../kotlin/com/zulip/flutter/MainActivity.kt | 35 +++- assets/l10n/app_en.arb | 16 ++ lib/generated/l10n/zulip_localizations.dart | 24 +++ .../l10n/zulip_localizations_ar.dart | 12 ++ .../l10n/zulip_localizations_en.dart | 12 ++ .../l10n/zulip_localizations_fr.dart | 12 ++ .../l10n/zulip_localizations_ja.dart | 12 ++ .../l10n/zulip_localizations_pl.dart | 12 ++ .../l10n/zulip_localizations_ru.dart | 12 ++ lib/widgets/lightbox.dart | 156 ++++++++++++++++++ pubspec.lock | 48 ++++++ pubspec.yaml | 1 + test/widgets/lightbox_test.dart | 17 ++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 15 files changed, 372 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt index 1829456362..acf49aaa3c 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,6 +1,39 @@ package com.zulip.flutter +import android.media.MediaScannerConnection +import android.net.Uri import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + private val CHANNEL = "gallery_saver" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + if (call.method == "scanFile") { + val filePath = call.argument("path") + if (filePath != null) { + scanFile(filePath) + result.success("MediaScanner invoked for $filePath") + } else { + result.error("INVALID_ARGUMENT", "File path is null", null) + } + } else { + result.notImplemented() + } + } + } + + private fun scanFile(filePath: String) { + MediaScannerConnection.scanFile( + applicationContext, + arrayOf(filePath), + null + ) { _, _ -> + // Intentionally left empty + } + } } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 58822303fd..3d2b8df282 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -395,6 +395,22 @@ "@lightboxCopyLinkTooltip": { "description": "Tooltip in lightbox for the copy link action." }, + "lightboxDownloadImageTooltip": "Download image", + "@lightboxDownloadImageTooltip": { + "description": "Tooltip in lightbox for the download image action." + }, + "lightboxDownloadImageSuccess": "Image downloaded successfully!", + "@lightboxDownloadImageSuccess": { + "description": "Message shown when the image downloads successfully." + }, + "lightboxDownloadImageFailed": "Failed to download the image.", + "@lightboxDownloadImageFailed": { + "description": "Message shown when the image download fails." + }, + "lightboxDownloadImageError": "An error occurred while downloading the image.", + "@lightboxDownloadImageError": { + "description": "Message shown when an unexpected error occurs during image download." + }, "loginPageTitle": "Log in", "@loginPageTitle": { "description": "Title for login page." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 00d7cfde72..565a8aa8e4 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -631,6 +631,30 @@ abstract class ZulipLocalizations { /// **'Copy link'** String get lightboxCopyLinkTooltip; + /// Tooltip in lightbox for the download image action. + /// + /// In en, this message translates to: + /// **'Download image'** + String get lightboxDownloadImageTooltip; + + /// Message shown when the image downloads successfully. + /// + /// In en, this message translates to: + /// **'Image downloaded successfully!'** + String get lightboxDownloadImageSuccess; + + /// Message shown when the image download fails. + /// + /// In en, this message translates to: + /// **'Failed to download the image.'** + String get lightboxDownloadImageFailed; + + /// Message shown when an unexpected error occurs during image download. + /// + /// In en, this message translates to: + /// **'An error occurred while downloading the image.'** + String get lightboxDownloadImageError; + /// Title for login page. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 542b85031b..23eca31d03 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index b6bc9f72e7..11c3b821dc 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index c857da2c82..9e14c9d62e 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 7adbc9ae8a..ebd6d1d8b2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 07746b3f27..084fadee8e 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Skopiuj odnośnik'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Zaloguj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9c2065376b..22f813c821 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -310,6 +310,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get lightboxCopyLinkTooltip => 'Copy link'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 7e4141db63..7fc69a39ba 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -3,6 +3,10 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:video_player/video_player.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; +import 'dart:async'; import '../api/core.dart'; import '../api/model/model.dart'; @@ -89,6 +93,157 @@ class _CopyLinkButton extends StatelessWidget { } } +// class _DownloadImageButton extends StatelessWidget { +// const _DownloadImageButton({required this.url}); + +// final Uri url; + +// static const platform = MethodChannel('gallery_saver'); + +// @override +// Widget build(BuildContext context) { +// final zulipLocalizations = ZulipLocalizations.of(context); +// return IconButton( +// tooltip: zulipLocalizations.lightboxDownloadImageTooltip, +// icon: const Icon(Icons.download), +// onPressed: () async { +// final scaffoldMessenger = ScaffoldMessenger.of(context); +// String message = zulipLocalizations.lightboxDownloadImageFailed; + +// try { +// // Fetch the image with a timeout +// final response = await http.get(url).timeout( +// const Duration(seconds: 30), +// onTimeout: () { +// throw TimeoutException("timed out"); +// }, +// ); +// if (response.statusCode == 200) { +// // Get the external storage directory +// final directory = await getExternalStorageDirectory(); +// if (directory == null) { +// message = zulipLocalizations.lightboxDownloadImageError; +// } else { +// final downloadPath = '${directory.path.split("Android")[0]}Download'; + +// // Create the Downloads folder if it doesn't exist +// final downloadFolder = Directory(downloadPath); +// if (!await downloadFolder.exists()) { +// await downloadFolder.create(recursive: true); +// } + +// final fileName = url.pathSegments.last; +// final filePath = '$downloadPath/$fileName'; + +// final file = File(filePath); +// await file.writeAsBytes(response.bodyBytes); + +// // Trigger Media Scanner so it reflects in the gallery. +// await platform.invokeMethod('scanFile', {'path': filePath}); + +// message = zulipLocalizations.lightboxDownloadImageSuccess; +// } +// } else { +// message = zulipLocalizations.lightboxDownloadImageFailed; +// } +// } catch (e) { +// if (e is TimeoutException || e is SocketException) { +// message = zulipLocalizations.lightboxDownloadImageError; +// } else { +// message = zulipLocalizations.lightboxDownloadImageError; +// } +// } + +// // Show a SnackBar notification + +// scaffoldMessenger.showSnackBar( +// SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)), +// ); +// } +// ); +// } +// } + +class _DownloadImageButton extends StatelessWidget { + const _DownloadImageButton({required this.url}); + + final Uri url; + + static const platform = MethodChannel('gallery_saver'); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return IconButton( + tooltip: zulipLocalizations.lightboxDownloadImageTooltip, + icon: const Icon(Icons.download), + onPressed: () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + String message = zulipLocalizations.lightboxDownloadImageFailed; + + try { + // Fetch the image with a timeout + final response = await http.get(url).timeout( + const Duration(seconds: 30), + onTimeout: () { + throw TimeoutException("timed out"); + }, + ); + + if (response.statusCode == 200) { + // Get the external storage directory + final directory = await getExternalStorageDirectory(); + if (directory == null) { + message = zulipLocalizations.lightboxDownloadImageError; + } else { + // Refactored to use MediaStore for Android 10+ (Scoped Storage) + if (Platform.isAndroid) { + final downloadFolder = await getDownloadDirectory(); + final fileName = url.pathSegments.last; + final filePath = '$downloadFolder/$fileName'; + + final file = File(filePath); + await file.writeAsBytes(response.bodyBytes); + + // Trigger Media Scanner so it reflects in the gallery. + await platform.invokeMethod('scanFile', {'path': filePath}); + + message = zulipLocalizations.lightboxDownloadImageSuccess; + } else { + message = zulipLocalizations.lightboxDownloadImageError; + } + } + } else { + message = zulipLocalizations.lightboxDownloadImageFailed; + } + } catch (e) { + if (e is TimeoutException || e is SocketException) { + message = zulipLocalizations.lightboxDownloadImageError; + } else { + message = zulipLocalizations.lightboxDownloadImageError; + } + } + + // Show a SnackBar notification + scaffoldMessenger.showSnackBar( + SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)), + ); + } + ); + } + + // Returns the download directory for Android 10+ using scoped storage + Future getDownloadDirectory() async { + if (Platform.isAndroid) { + final directory = await getExternalStorageDirectory(); + final downloadFolder = '${directory?.path.split("Android")[0]}Download'; + return downloadFolder; + } + return ''; + } +} + + class _LightboxPageLayout extends StatefulWidget { const _LightboxPageLayout({ required this.routeEntranceAnimation, @@ -258,6 +413,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { elevation: elevation, child: Row(children: [ _CopyLinkButton(url: widget.src), + _DownloadImageButton(url: widget.src) // TODO(#43): Share image // TODO(#42): Download image ]), diff --git a/pubspec.lock b/pubspec.lock index 2ff00b2b9c..bfd8846c61 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -835,6 +835,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + url: "https://pub.dev" + source: hosted + version: "12.0.13" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 780c057653..61d79e5d2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin + permission_handler: ^11.3.1 # Keep list sorted when adding dependencies; it helps prevent merge conflicts. dependency_overrides: diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 4a84f79b27..f343118024 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -275,6 +275,23 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + testWidgets('download button triggers download', (tester) async { + prepareBoringImageHttpClient(); + final message = eg.streamMessage(sender: eg.otherUser); + await setupPage(tester, message: message, thumbnailUrl: null); + + final downloadButton = find.byIcon(Icons.download); + + expect(downloadButton, findsOneWidget); + await tester.tap(downloadButton); + await tester.pump(); + + final snackbar = find.byType(SnackBar); + expect(snackbar, findsOneWidget); + + debugNetworkImageHttpClientProvider = null; + }); + // TODO test _CopyLinkButton // TODO test thumbnail gets shown, then gets replaced when main image loads // TODO test image is scaled down to fit, but not up diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0d4b4d65c2..4a2d73bf64 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4a4d9be3e7..30c3ca4786 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core + permission_handler_windows share_plus sqlite3_flutter_libs url_launcher_windows