diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index cf816f50c4..9c2ff942e5 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -338,6 +338,10 @@ "@errorDialogTitle": { "description": "Generic title for error dialog." }, + "errorShareFailed": "Error Sharing the Image", + "@errorShareFailed": { + "description": "Title for sharing image error dialog." + }, "snackBarDetails": "Details", "@snackBarDetails": { "description": "Button label for snack bar button that opens a dialog with more details." @@ -346,6 +350,10 @@ "@lightboxCopyLinkTooltip": { "description": "Tooltip in lightbox for the copy link action." }, + "lightboxShareImageTooltip": "Share Image", + "@lightboxShareImageTooltip": { + "description": "Tooltip in lightbox for the Share Image action." + }, "loginPageTitle": "Log in", "@loginPageTitle": { "description": "Page title for login page." diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 7e4141db63..e4248f079d 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as httpClient; import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:video_player/video_player.dart'; import '../api/core.dart'; @@ -89,6 +91,46 @@ class _CopyLinkButton extends StatelessWidget { } } +Future _downloadImage(Uri url, Map headers) async { + final response = await httpClient.get(url, headers: headers); + final bytes = response.bodyBytes; + return XFile.fromData(bytes, + name: url.pathSegments.last, + mimeType: response.headers['content-type']); +} + +class _ShareButton extends StatelessWidget { + const _ShareButton({required this.url}); + + final Uri url; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return IconButton( + tooltip: zulipLocalizations.lightboxShareImageTooltip, + icon: const Icon(Icons.share), + onPressed: () async { + try { + final store = PerAccountStoreWidget.of(context); + final headers = { + if (url.origin == store.account.realmUrl.origin) + ...authHeader(email: store.account.email, apiKey: store.account.apiKey), + ...userAgentHeader() + }; + final xFile = await _downloadImage(url, headers); + await Share.shareXFiles([xFile]); + } catch (error) { + if (!context.mounted) return; + showErrorDialog( + context: context, + title: zulipLocalizations.errorDialogTitle, + message: zulipLocalizations.errorShareFailed); + } + }); + } +} + class _LightboxPageLayout extends StatefulWidget { const _LightboxPageLayout({ required this.routeEntranceAnimation, @@ -258,6 +300,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { elevation: elevation, child: Row(children: [ _CopyLinkButton(url: widget.src), + _ShareButton(url: widget.src), // TODO(#43): Share image // TODO(#42): Download image ]), diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 4a84f79b27..e169838168 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -275,6 +275,29 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + testWidgets('share button shows correct icon and downloads image', (tester) async { + prepareBoringImageHttpClient(); + final message = eg.streamMessage(); + await setupPage(tester, message: message, thumbnailUrl: null); + + // Verify share icon exists + final shareIcon = find.descendant( + of: find.byType(BottomAppBar), + matching: find.byIcon(Icons.share), + skipOffstage: false); + check(tester.widget(shareIcon).icon).equals(Icons.share); + + // Verify tooltip + final button = tester.widget(find.ancestor( + of: shareIcon, + matching: find.byType(IconButton))); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(button.tooltip).equals(zulipLocalizations.lightboxShareImageTooltip); + + 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