@@ -3,6 +3,10 @@ import 'package:flutter/scheduler.dart';
33import 'package:flutter/services.dart' ;
44import 'package:intl/intl.dart' ;
55import 'package:video_player/video_player.dart' ;
6+ import 'package:http/http.dart' as http;
7+ import 'package:path_provider/path_provider.dart' ;
8+ import 'dart:io' ;
9+ import 'dart:async' ;
610
711import '../api/core.dart' ;
812import '../api/model/model.dart' ;
@@ -89,6 +93,95 @@ class _CopyLinkButton extends StatelessWidget {
8993 }
9094}
9195
96+ class _DownloadImageButton extends StatelessWidget {
97+ const _DownloadImageButton ({required this .url});
98+
99+ final Uri url;
100+
101+ static const platform = MethodChannel ('gallery_saver' );
102+
103+ @override
104+ Widget build (BuildContext context) {
105+ final store = PerAccountStoreWidget .of (context);
106+ final zulipLocalizations = ZulipLocalizations .of (context);
107+ return IconButton (
108+ tooltip: zulipLocalizations.lightboxDownloadImageTooltip,
109+ icon: const Icon (Icons .download),
110+ onPressed: () async {
111+ final scaffoldMessenger = ScaffoldMessenger .of (context);
112+ String message = zulipLocalizations.lightboxDownloadImageFailed;
113+ try {
114+ // Fetch the image with a timeout
115+ final response = await http.get (
116+ url,
117+ headers: {
118+ if (url.origin == store.account.realmUrl.origin) ...authHeader (
119+ email: store.account.email,
120+ apiKey: store.account.apiKey,
121+ ),
122+ ...userAgentHeader ()
123+ }
124+ ).timeout (
125+ const Duration (seconds: 30 ),
126+ onTimeout: () {
127+ throw TimeoutException ("timed out" );
128+ },
129+ );
130+
131+ if (response.statusCode == 200 ) {
132+ // Get the external storage directory
133+ final directory = await getExternalStorageDirectory ();
134+ if (directory == null ) {
135+ message = zulipLocalizations.lightboxDownloadImageError;
136+ } else {
137+ // Refactored to use MediaStore for Android 10+ (Scoped Storage)
138+ if (Platform .isAndroid) {
139+ final downloadFolder = await getDownloadDirectory ();
140+ final fileName = url.pathSegments.last;
141+ final filePath = '$downloadFolder /$fileName ' ;
142+
143+ final file = File (filePath);
144+ await file.writeAsBytes (response.bodyBytes);
145+
146+ // Trigger Media Scanner so it reflects in the gallery.
147+ await platform.invokeMethod ('scanFile' , {'path' : filePath});
148+
149+ message = zulipLocalizations.lightboxDownloadImageSuccess;
150+ } else {
151+ message = zulipLocalizations.lightboxDownloadImageError;
152+ }
153+ }
154+ } else {
155+ message = zulipLocalizations.lightboxDownloadImageFailed;
156+ }
157+ } catch (e) {
158+ if (e is TimeoutException || e is SocketException ) {
159+ message = zulipLocalizations.lightboxDownloadImageError;
160+ } else {
161+ message = zulipLocalizations.lightboxDownloadImageError;
162+ }
163+ }
164+
165+ // Show a SnackBar notification
166+ scaffoldMessenger.showSnackBar (
167+ SnackBar (behavior: SnackBarBehavior .floating, content: Text (message)),
168+ );
169+ }
170+ );
171+ }
172+
173+ // Returns the download directory for Android 10+ using scoped storage
174+ Future <String > getDownloadDirectory () async {
175+ if (Platform .isAndroid) {
176+ final directory = await getExternalStorageDirectory ();
177+ final downloadFolder = '${directory ?.path .split ("Android" )[0 ]}Download' ;
178+ return downloadFolder;
179+ }
180+ return '' ;
181+ }
182+ }
183+
184+
92185class _LightboxPageLayout extends StatefulWidget {
93186 const _LightboxPageLayout ({
94187 required this .routeEntranceAnimation,
@@ -258,6 +351,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
258351 elevation: elevation,
259352 child: Row (children: [
260353 _CopyLinkButton (url: widget.src),
354+ _DownloadImageButton (url: widget.src)
261355 // TODO(#43): Share image
262356 // TODO(#42): Download image
263357 ]),
0 commit comments