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