Line data Source code
1 : import 'dart:io';
2 : import 'dart:math';
3 :
4 : import 'package:cwtch/config.dart';
5 : import 'package:cwtch/cwtch/cwtch.dart';
6 : import 'package:cwtch/cwtch_icons_icons.dart';
7 : import 'package:cwtch/models/contact.dart';
8 : import 'package:cwtch/models/filedownloadprogress.dart';
9 : import 'package:cwtch/models/message.dart';
10 : import 'package:cwtch/models/profile.dart';
11 : import 'package:cwtch/themes/opaque.dart';
12 : import 'package:cwtch/widgets/malformedbubble.dart';
13 : import 'package:cwtch/widgets/messageBubbleWidgetHelpers.dart';
14 : import 'package:file_picker/file_picker.dart';
15 : import 'package:flutter/material.dart';
16 : import 'package:provider/provider.dart';
17 : import '../main.dart';
18 :
19 : import 'package:flutter_gen/gen_l10n/app_localizations.dart';
20 :
21 : import '../models/redaction.dart';
22 : import '../settings.dart';
23 : import 'messagebubbledecorations.dart';
24 :
25 : // Like MessageBubble but for displaying chat overlay 100/101 invitations
26 : // Offers the user an accept/reject button if they don't have a matching contact already
27 : class FileBubble extends StatefulWidget {
28 : final String nameSuggestion;
29 : final String rootHash;
30 : final String nonce;
31 : final int fileSize;
32 : final bool interactive;
33 : final bool isAuto;
34 : final bool isPreview;
35 :
36 0 : FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize, {this.isAuto = false, this.interactive = true, this.isPreview = false});
37 :
38 0 : @override
39 0 : FileBubbleState createState() => FileBubbleState();
40 :
41 0 : String fileKey() {
42 0 : return this.rootHash + "." + this.nonce;
43 : }
44 : }
45 :
46 : class FileBubbleState extends State<FileBubble> {
47 : File? myFile;
48 :
49 0 : @override
50 : void initState() {
51 0 : super.initState();
52 : }
53 :
54 0 : Widget getPreview(context) {
55 : const constraintSize = 150.0;
56 0 : final imageSize = MediaQuery.sizeOf(context);
57 0 : final aspectRatio = imageSize.width / imageSize.height;
58 0 : final widgetWidth = (constraintSize * MediaQuery.devicePixelRatioOf(context)).round();
59 0 : final memCacheWidth = min(widgetWidth, imageSize.width);
60 0 : return Container(
61 0 : constraints: BoxConstraints(maxHeight: min(constraintSize, max(imageSize.width, imageSize.height))),
62 0 : child: Image.file(
63 0 : myFile!,
64 : // limit the amount of space the image can decode too, we keep this high-ish to allow quality previews...
65 0 : cacheWidth: memCacheWidth.round(),
66 : filterQuality: FilterQuality.medium,
67 : fit: BoxFit.scaleDown,
68 : alignment: Alignment.center,
69 : isAntiAlias: false,
70 0 : errorBuilder: (context, error, stackTrace) {
71 0 : return MalformedBubble();
72 : },
73 : ));
74 : }
75 :
76 0 : @override
77 : Widget build(BuildContext context) {
78 0 : var fromMe = Provider.of<MessageMetadata>(context).senderHandle == Provider.of<ProfileInfoState>(context).onion;
79 0 : var flagStarted = Provider.of<MessageMetadata>(context).attributes["file-downloaded"] == "true";
80 : var borderRadius = 15.0;
81 0 : var showFileSharing = Provider.of<Settings>(context).isExperimentEnabled(FileSharingExperiment);
82 0 : var showImages = Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment);
83 0 : DateTime messageDate = Provider.of<MessageMetadata>(context).timestamp;
84 :
85 0 : var metadata = Provider.of<MessageMetadata>(context);
86 0 : var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey());
87 :
88 : // If we haven't stored the filepath in message attributes then save it
89 0 : if (metadata.attributes["filepath"] != null && metadata.attributes["filepath"].toString().isNotEmpty) {
90 0 : path = metadata.attributes["filepath"];
91 0 : } else if (path != null && metadata.attributes["filepath"] == null) {
92 0 : Provider.of<FlwtchState>(context).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", path);
93 : }
94 :
95 : // the file is downloaded when it is from the sender AND the path is known OR when we get an explicit downloadComplete
96 0 : var downloadComplete = (fromMe && path != null) || Provider.of<ProfileInfoState>(context).downloadComplete(widget.fileKey());
97 0 : var downloadInterrupted = Provider.of<ProfileInfoState>(context).downloadInterrupted(widget.fileKey());
98 :
99 : var isImagePreview = false;
100 : if (path != null) {
101 0 : isImagePreview = Provider.of<Settings>(context).isImage(path);
102 : }
103 :
104 : if (downloadComplete && path != null) {
105 : if (isImagePreview) {
106 0 : if (myFile == null || myFile?.path != path) {
107 0 : myFile = new File(path);
108 : // reset
109 0 : if (myFile?.existsSync() == false) {
110 0 : myFile = null;
111 : //Provider.of<ProfileInfoState>(context, listen: false).downloadReset(widget.fileKey());
112 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["filepath"] = null;
113 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-downloaded"] = "false";
114 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-missing"] = "true";
115 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-downloaded", "false");
116 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", "");
117 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "true");
118 : } else {
119 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-missing"] = "false";
120 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "false");
121 0 : setState(() {});
122 : }
123 : }
124 : }
125 : }
126 :
127 0 : var downloadActive = Provider.of<ProfileInfoState>(context).downloadActive(widget.fileKey());
128 0 : var downloadGotManifest = Provider.of<ProfileInfoState>(context).downloadGotManifest(widget.fileKey());
129 :
130 0 : var messageStatusWidget = MessageBubbleDecoration(ackd: metadata.ackd, errored: metadata.error, messageDate: messageDate, fromMe: fromMe);
131 :
132 : // If the sender is not us, then we want to give them a nickname...
133 : var senderDisplayStr = "";
134 : var senderIsContact = false;
135 : if (!fromMe) {
136 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context).contactList.findContact(Provider.of<MessageMetadata>(context).senderHandle);
137 : if (contact != null) {
138 0 : senderDisplayStr = redactedNick(context, contact.onion, contact.nickname);
139 : senderIsContact = true;
140 : } else {
141 0 : senderDisplayStr = Provider.of<MessageMetadata>(context).senderHandle;
142 : }
143 : }
144 :
145 : // if we should show a preview i.e. we are in a quote bubble
146 : // then do that here...
147 0 : if (showImages && isImagePreview && widget.isPreview && myFile != null) {
148 : // if the image exists then just show the image as a preview
149 0 : return getPreview(context);
150 0 : } else if (showFileSharing && widget.isPreview) {
151 : // otherwise just show a summary...
152 0 : return Row(
153 0 : children: [
154 0 : Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromMeTextColor),
155 0 : Flexible(child: Text(widget.nameSuggestion, style: TextStyle(fontWeight: FontWeight.bold, fontFamily: "Inter", color: Provider.of<Settings>(context).theme.messageFromMeTextColor)))
156 : ],
157 : );
158 : }
159 :
160 0 : var wdgSender = Visibility(
161 0 : visible: widget.interactive,
162 0 : child: Container(
163 0 : height: 14 * Provider.of<Settings>(context).fontScaling, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: compileSenderWidget(context, null, fromMe, senderDisplayStr)));
164 : var isPreview = false;
165 : var wdgMessage = !showFileSharing
166 0 : ? Text(AppLocalizations.of(context)!.messageEnableFileSharing, style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle))
167 : : fromMe
168 0 : ? senderFileChrome(AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash)
169 0 : : (fileChrome(AppLocalizations.of(context)!.messageFileOffered + ":", widget.nameSuggestion, widget.rootHash, widget.fileSize,
170 0 : Provider.of<ProfileInfoState>(context).downloadSpeed(widget.fileKey())));
171 : Widget wdgDecorations;
172 :
173 : if (!showFileSharing) {
174 0 : wdgDecorations = Text('\u202F');
175 : } else if ((fromMe || downloadComplete) && path != null) {
176 : // in this case, whatever marked download.complete would have also set the path
177 0 : if (myFile != null && Provider.of<Settings>(context).shouldPreview(path)) {
178 : isPreview = true;
179 0 : wdgDecorations = Center(
180 : widthFactor: 1.0,
181 0 : child: MouseRegion(
182 : cursor: SystemMouseCursors.click,
183 0 : child: GestureDetector(
184 0 : child: Padding(padding: EdgeInsets.all(1.0), child: getPreview(context)),
185 0 : onTap: () {
186 0 : pop(context, myFile!, widget.nameSuggestion);
187 : },
188 : )));
189 : } else if (fromMe) {
190 0 : wdgDecorations = Text('\u202F');
191 : } else {
192 0 : wdgDecorations = Visibility(
193 0 : visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.fileSavedTo + ': ' + path + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)));
194 : }
195 : } else if (downloadActive) {
196 : if (!downloadGotManifest) {
197 0 : wdgDecorations = Visibility(
198 0 : visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)));
199 : } else {
200 0 : wdgDecorations = Visibility(
201 0 : visible: widget.interactive,
202 0 : child: LinearProgressIndicator(
203 0 : value: Provider.of<ProfileInfoState>(context).downloadProgress(widget.fileKey()),
204 0 : color: Provider.of<Settings>(context).theme.defaultButtonActiveColor,
205 : ));
206 : }
207 : } else if (flagStarted) {
208 : // in this case, the download was done in a previous application launch,
209 : // so we probably have to request an info lookup
210 : if (!downloadInterrupted) {
211 0 : wdgDecorations = Text(AppLocalizations.of(context)!.fileCheckingStatus + '...' + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle));
212 : // We should have already requested this...
213 : } else {
214 0 : var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey()) ?? "";
215 0 : wdgDecorations = Visibility(
216 0 : visible: widget.interactive,
217 0 : child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
218 0 : Text(AppLocalizations.of(context)!.fileInterrupted + ': ' + path + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)),
219 0 : ElevatedButton(onPressed: _btnResume, child: Text(AppLocalizations.of(context)!.verfiyResumeButton, style: Provider.of<Settings>(context).scaleFonts(defaultTextButtonStyle)))
220 : ]));
221 : }
222 : } else if (!senderIsContact) {
223 0 : wdgDecorations = Text(AppLocalizations.of(context)!.msgAddToAccept, style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle));
224 0 : } else if (!widget.isAuto || Provider.of<MessageMetadata>(context).attributes["file-missing"] == "false") {
225 : //Note: we need this second case to account for scenarios where a user deletes the downloaded file, we won't automatically
226 : // fetch it again, so we need to offer the user the ability to restart..
227 0 : wdgDecorations = Visibility(
228 0 : visible: widget.interactive,
229 0 : child: Center(
230 : widthFactor: 1,
231 0 : child: Wrap(children: [
232 0 : Padding(
233 0 : padding: EdgeInsets.all(5),
234 0 : child: ElevatedButton(
235 0 : child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextButtonStyle)), onPressed: _btnAccept)),
236 : ])));
237 : } else {
238 0 : wdgDecorations = Container();
239 : }
240 :
241 0 : return Container(
242 0 : constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3),
243 0 : decoration: BoxDecoration(
244 0 : color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor,
245 0 : border: Border.all(color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor, width: 1),
246 0 : borderRadius: BorderRadius.only(
247 0 : topLeft: Radius.circular(borderRadius),
248 0 : topRight: Radius.circular(borderRadius),
249 0 : bottomLeft: fromMe ? Radius.circular(borderRadius) : Radius.zero,
250 0 : bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadius),
251 : ),
252 : ),
253 0 : child: Theme(
254 0 : data: Theme.of(context).copyWith(
255 0 : textSelectionTheme: TextSelectionThemeData(
256 0 : cursorColor: Provider.of<Settings>(context).theme.messageSelectionColor,
257 0 : selectionColor: Provider.of<Settings>(context).theme.messageSelectionColor,
258 0 : selectionHandleColor: Provider.of<Settings>(context).theme.messageSelectionColor),
259 :
260 : // Horrifying Hack: Flutter doesn't give us direct control over system menus but instead picks BG color from TextButtonThemeData ¯\_(ツ)_/¯
261 0 : textButtonTheme: TextButtonThemeData(
262 0 : style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Provider.of<Settings>(context).theme.menuBackgroundColor)),
263 : ),
264 : ),
265 0 : child: Padding(
266 0 : padding: EdgeInsets.all(9.0),
267 0 : child: Column(
268 : crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
269 : mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start,
270 : mainAxisSize: MainAxisSize.min,
271 0 : children: [
272 : wdgSender,
273 : isPreview
274 0 : ? Container(
275 : width: 0,
276 : padding: EdgeInsets.zero,
277 : margin: EdgeInsets.zero,
278 : )
279 : : wdgMessage,
280 : wdgDecorations,
281 : messageStatusWidget
282 : ]),
283 : )));
284 : }
285 :
286 0 : void _btnAccept() async {
287 : String? selectedFileName;
288 : File? file;
289 0 : var profileOnion = Provider.of<ProfileInfoState>(context, listen: false).onion;
290 0 : var conversation = Provider.of<ContactInfoState>(context, listen: false).identifier;
291 0 : var idx = Provider.of<MessageMetadata>(context, listen: false).messageID;
292 :
293 0 : if (Platform.isAndroid) {
294 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
295 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true");
296 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context, listen: false).contactList.findContact(Provider.of<MessageMetadata>(context, listen: false).senderHandle);
297 : if (contact != null) {
298 0 : var manifestPath = Provider.of<Settings>(context, listen: false).downloadPath + "/" + widget.fileKey() + ".manifest";
299 :
300 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, contact.identifier, widget.nameSuggestion, widget.fileKey(), manifestPath);
301 : }
302 : } else {
303 : try {
304 0 : var downloadPath = Provider.of<Settings>(context, listen: false).downloadPath;
305 0 : if (downloadPath == "") {
306 0 : downloadPath = Provider.of<FlwtchState>(context, listen: false).cwtch.defaultDownloadPath() ?? "";
307 : }
308 :
309 0 : selectedFileName = await FilePicker.platform.saveFile(
310 0 : fileName: widget.nameSuggestion,
311 : initialDirectory: downloadPath,
312 : lockParentWindow: true,
313 : );
314 : if (selectedFileName != null) {
315 0 : file = File(selectedFileName);
316 0 : EnvironmentConfig.debugLog("saving to " + file.path);
317 0 : var manifestPath = file.path + ".manifest";
318 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
319 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true");
320 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context, listen: false).contactList.findContact(Provider.of<MessageMetadata>(context, listen: false).senderHandle);
321 : if (contact != null) {
322 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, contact.identifier, file.path, manifestPath, widget.fileKey());
323 : }
324 : }
325 : } catch (e) {
326 0 : print(e);
327 : }
328 : }
329 : }
330 :
331 0 : void _btnResume() async {
332 0 : var profileOnion = Provider.of<ProfileInfoState>(context, listen: false).onion;
333 0 : var handle = Provider.of<MessageMetadata>(context, listen: false).conversationIdentifier;
334 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadMarkResumed(widget.fileKey());
335 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.VerifyOrResumeDownload(profileOnion, handle, widget.fileKey());
336 : }
337 :
338 : // Construct an file chrome for the sender
339 0 : Widget senderFileChrome(String chrome, String fileName, String rootHash) {
340 0 : var settings = Provider.of<Settings>(context);
341 0 : return ListTile(
342 : visualDensity: VisualDensity.compact,
343 0 : contentPadding: EdgeInsets.all(1.0),
344 0 : title: SelectableText(
345 0 : fileName + '\u202F',
346 : style:
347 0 : settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of<Settings>(context).theme.messageFromMeTextColor)),
348 : textAlign: TextAlign.left,
349 : textWidthBasis: TextWidthBasis.longestLine,
350 : maxLines: 2,
351 : ),
352 0 : subtitle: SelectableText(
353 0 : prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F',
354 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of<Settings>(context).theme.messageFromMeTextColor)),
355 : textAlign: TextAlign.left,
356 : maxLines: 4,
357 : textWidthBasis: TextWidthBasis.longestLine,
358 : ),
359 0 : leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)));
360 : }
361 :
362 : // Construct an file chrome
363 0 : Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) {
364 0 : var settings = Provider.of<Settings>(context);
365 0 : return ListTile(
366 : visualDensity: VisualDensity.compact,
367 0 : contentPadding: EdgeInsets.all(1.0),
368 0 : title: SelectableText(
369 0 : fileName + '\u202F',
370 : style:
371 0 : settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
372 : textAlign: TextAlign.left,
373 : textWidthBasis: TextWidthBasis.longestLine,
374 : maxLines: 2,
375 : ),
376 0 : subtitle: SelectableText(
377 0 : prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F',
378 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
379 : textAlign: TextAlign.left,
380 : maxLines: 4,
381 : textWidthBasis: TextWidthBasis.longestLine,
382 : ),
383 0 : leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
384 : // Note: not using Visible here because we want to shrink this to nothing when not in use...
385 0 : trailing: speed == "0 B/s"
386 : ? null
387 0 : : SelectableText(
388 0 : speed + '\u202F',
389 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
390 : textAlign: TextAlign.left,
391 : maxLines: 1,
392 : textWidthBasis: TextWidthBasis.longestLine,
393 : ),
394 : );
395 : }
396 :
397 0 : void pop(context, File myFile, String meta) async {
398 0 : await showDialog(
399 : context: context,
400 0 : builder: (bcontext) => Dialog(
401 : alignment: Alignment.topCenter,
402 0 : child: SingleChildScrollView(
403 0 : controller: ScrollController(),
404 0 : child: Container(
405 0 : padding: EdgeInsets.all(10),
406 0 : child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [
407 0 : ListTile(
408 0 : leading: Icon(CwtchIcons.attached_file_3),
409 0 : title: Text(meta),
410 0 : trailing: IconButton(
411 0 : icon: Icon(Icons.close),
412 0 : color: Provider.of<Settings>(bcontext, listen: false).theme.mainTextColor,
413 : iconSize: 32,
414 0 : onPressed: () {
415 0 : Navigator.pop(bcontext, true);
416 : })),
417 0 : Padding(
418 0 : padding: EdgeInsets.all(10),
419 0 : child: Image.file(
420 : myFile,
421 0 : cacheWidth: (MediaQuery.of(bcontext).size.width * 0.6).floor(),
422 0 : width: (MediaQuery.of(bcontext).size.width * 0.6),
423 0 : height: (MediaQuery.of(bcontext).size.height * 0.6),
424 : fit: BoxFit.scaleDown,
425 : )),
426 0 : Visibility(visible: !Platform.isAndroid, maintainSize: false, child: Text(myFile.path, textAlign: TextAlign.center)),
427 0 : Visibility(
428 0 : visible: Platform.isAndroid,
429 : maintainSize: false,
430 0 : child: Padding(
431 0 : padding: EdgeInsets.all(10),
432 0 : child: ElevatedButton.icon(
433 0 : icon: Icon(Icons.arrow_downward),
434 0 : onPressed: androidExport,
435 0 : label: Text(
436 0 : AppLocalizations.of(bcontext)!.saveBtn,
437 : )))),
438 : ]),
439 : ))));
440 : }
441 :
442 0 : void androidExport() async {
443 0 : if (myFile != null) {
444 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.ExportPreviewedFile(myFile!.path, widget.nameSuggestion);
445 : }
446 : }
447 : }
|