@@ -3,6 +3,10 @@ import 'package:flutter/scheduler.dart';
3
3
import 'package:flutter/services.dart' ;
4
4
import 'package:intl/intl.dart' ;
5
5
import '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' ;
6
10
7
11
import '../api/core.dart' ;
8
12
import '../api/model/model.dart' ;
@@ -89,6 +93,95 @@ class _CopyLinkButton extends StatelessWidget {
89
93
}
90
94
}
91
95
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
+
92
185
class _LightboxPageLayout extends StatefulWidget {
93
186
const _LightboxPageLayout ({
94
187
required this .routeEntranceAnimation,
@@ -258,6 +351,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
258
351
elevation: elevation,
259
352
child: Row (children: [
260
353
_CopyLinkButton (url: widget.src),
354
+ _DownloadImageButton (url: widget.src)
261
355
// TODO(#43): Share image
262
356
// TODO(#42): Download image
263
357
]),
0 commit comments