Skip to content

Commit cda6995

Browse files
FilePicker and uploads (#258)
* A few server-side tests * FilePicker without upload * Allow multiple copies of Flet window on macOS Fix #249 * Upload dir on Python side, upload URL gen * FilePicker with upload * Upload progress complete * offstage -> overlay * Disable CORS * FilePicker methods with parameters * Renamed again * gtk_widget_realize() Based on leanflutter/window_manager#206
1 parent 010b549 commit cda6995

29 files changed

+832
-53
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# Dependency directories (remove the comment below to include it)
1616
# vendor/
1717

18+
# VS Code
19+
.vscode/
20+
1821
# mac specific
1922
.DS_Store
2023
*.bkp

client/linux/my_application.cc

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ static void my_application_activate(GApplication* application) {
4848
}
4949

5050
gtk_window_set_default_size(window, 1280, 720);
51-
gtk_widget_show(GTK_WIDGET(window));
52-
gtk_widget_hide(GTK_WIDGET(window));
51+
gtk_widget_realize(GTK_WIDGET(window));
5352

5453
g_autoptr(FlDartProject) project = fl_dart_project_new();
5554
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);

client/pubspec.lock

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ packages:
7878
url: "https://pub.dartlang.org"
7979
source: hosted
8080
version: "1.3.1"
81+
ffi:
82+
dependency: transitive
83+
description:
84+
name: ffi
85+
url: "https://pub.dartlang.org"
86+
source: hosted
87+
version: "2.0.1"
88+
file_picker:
89+
dependency: transitive
90+
description:
91+
name: file_picker
92+
url: "https://pub.dartlang.org"
93+
source: hosted
94+
version: "5.0.1"
8195
flet:
8296
dependency: "direct main"
8397
description:
@@ -111,6 +125,13 @@ packages:
111125
url: "https://pub.dartlang.org"
112126
source: hosted
113127
version: "0.6.10+3"
128+
flutter_plugin_android_lifecycle:
129+
dependency: transitive
130+
description:
131+
name: flutter_plugin_android_lifecycle
132+
url: "https://pub.dartlang.org"
133+
source: hosted
134+
version: "2.0.7"
114135
flutter_redux:
115136
dependency: transitive
116137
description:
@@ -357,6 +378,13 @@ packages:
357378
url: "https://pub.dartlang.org"
358379
source: hosted
359380
version: "2.1.0"
381+
win32:
382+
dependency: transitive
383+
description:
384+
name: win32
385+
url: "https://pub.dartlang.org"
386+
source: hosted
387+
version: "2.7.0"
360388
window_manager:
361389
dependency: transitive
362390
description:
@@ -379,5 +407,5 @@ packages:
379407
source: hosted
380408
version: "3.1.1"
381409
sdks:
382-
dart: ">=2.17.0-0 <3.0.0"
410+
dart: ">=2.17.0 <3.0.0"
383411
flutter: ">=3.0.0"

package/lib/src/controls/container.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:convert';
22
import 'dart:typed_data';
33

44
import 'package:collection/collection.dart';
5-
import '../protocol/container_tap_event_data.dart';
5+
import '../protocol/container_tap_event.dart';
66
import '../utils/animations.dart';
77
import 'package:flutter/material.dart';
88
import 'package:flutter_redux/flutter_redux.dart';
@@ -207,7 +207,7 @@ class ContainerControl extends StatelessWidget {
207207
ws.pageEventFromWeb(
208208
eventTarget: control.id,
209209
eventName: "click",
210-
eventData: json.encode(ContainerTapEventData(
210+
eventData: json.encode(ContainerTapEvent(
211211
localX: details.localPosition.dx,
212212
localY: details.localPosition.dy,
213213
globalX: details.globalPosition.dx,

package/lib/src/controls/create_control.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import 'drag_target.dart';
2323
import 'draggable.dart';
2424
import 'dropdown.dart';
2525
import 'elevated_button.dart';
26+
import 'file_picker.dart';
2627
import 'floating_action_button.dart';
2728
import 'grid_view.dart';
2829
import 'icon.dart';
@@ -82,6 +83,9 @@ Widget createControl(Control? parent, String id, bool parentDisabled) {
8283
return TextControl(parent: parent, control: controlView.control);
8384
case ControlType.icon:
8485
return IconControl(parent: parent, control: controlView.control);
86+
case ControlType.filePicker:
87+
return FilePickerControl(
88+
parent: parent, control: controlView.control);
8589
case ControlType.markdown:
8690
return MarkdownControl(parent: parent, control: controlView.control);
8791
case ControlType.clipboard:
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import 'dart:convert';
2+
3+
import 'package:collection/collection.dart';
4+
import 'package:file_picker/file_picker.dart';
5+
import 'package:flet/src/protocol/file_picker_upload_file.dart';
6+
import 'package:flet/src/protocol/file_picker_upload_progress_event.dart';
7+
import 'package:flet/src/web_socket_client.dart';
8+
import '../protocol/file_picker_result_event.dart';
9+
import 'package:flutter/foundation.dart';
10+
import 'package:flutter/material.dart';
11+
import 'package:flutter_redux/flutter_redux.dart';
12+
import 'package:http/http.dart' as http;
13+
14+
import '../actions.dart';
15+
import '../flet_app_services.dart';
16+
import '../models/app_state.dart';
17+
import '../models/control.dart';
18+
import '../protocol/update_control_props_payload.dart';
19+
import '../utils/strings.dart';
20+
21+
class FilePickerControl extends StatefulWidget {
22+
final Control? parent;
23+
final Control control;
24+
25+
const FilePickerControl(
26+
{Key? key, required this.parent, required this.control})
27+
: super(key: key);
28+
29+
@override
30+
State<FilePickerControl> createState() => _FilePickerControlState();
31+
}
32+
33+
class _FilePickerControlState extends State<FilePickerControl> {
34+
String? _state;
35+
String? _upload;
36+
String? _path;
37+
List<PlatformFile>? _files;
38+
39+
@override
40+
Widget build(BuildContext context) {
41+
debugPrint("FilePicker build: ${widget.control.id}");
42+
43+
return StoreConnector<AppState, Uri?>(
44+
distinct: true,
45+
converter: (store) => store.state.pageUri,
46+
builder: (context, pageUri) {
47+
var state = widget.control.attrString("state");
48+
var upload = widget.control.attrString("upload");
49+
var dialogTitle = widget.control.attrString("dialogTitle");
50+
var fileName = widget.control.attrString("fileName");
51+
var initialDirectory = widget.control.attrString("initialDirectory");
52+
var allowMultiple = widget.control.attrBool("allowMultiple", false)!;
53+
var allowedExtensions =
54+
parseStringList(widget.control, "allowedExtensions");
55+
FileType fileType = FileType.values.firstWhere(
56+
(m) =>
57+
m.name.toLowerCase() ==
58+
widget.control.attrString("fileType", "")!.toLowerCase(),
59+
orElse: () => FileType.any);
60+
if (allowedExtensions != null && allowedExtensions.isNotEmpty) {
61+
fileType = FileType.custom;
62+
}
63+
64+
debugPrint("FilePicker _state: $_state, state: $state");
65+
66+
sendEvent() {
67+
_state = null;
68+
var fletServices = FletAppServices.of(context);
69+
List<Map<String, String>> props = [
70+
{"i": widget.control.id, "state": ""}
71+
];
72+
fletServices.store.dispatch(UpdateControlPropsAction(
73+
UpdateControlPropsPayload(props: props)));
74+
fletServices.ws.updateControlProps(props: props);
75+
fletServices.ws.pageEventFromWeb(
76+
eventTarget: widget.control.id,
77+
eventName: "result",
78+
eventData: json.encode(FilePickerResultEvent(
79+
path: _path,
80+
files: _files
81+
?.map((f) => FilePickerFile(
82+
name: f.name,
83+
path: kIsWeb ? null : f.path,
84+
size: f.size))
85+
.toList())));
86+
}
87+
88+
if (_state != state) {
89+
_path = null;
90+
_files = null;
91+
92+
// pickFiles
93+
if (state?.toLowerCase() == "pickfiles") {
94+
FilePicker.platform
95+
.pickFiles(
96+
dialogTitle: dialogTitle,
97+
initialDirectory: initialDirectory,
98+
lockParentWindow: true,
99+
type: fileType,
100+
allowedExtensions: allowedExtensions,
101+
allowMultiple: allowMultiple,
102+
withData: false,
103+
withReadStream: true)
104+
.then((result) {
105+
debugPrint("pickFiles() completed");
106+
_files = result?.files;
107+
sendEvent();
108+
});
109+
}
110+
// saveFile
111+
else if (state?.toLowerCase() == "savefile" && !kIsWeb) {
112+
FilePicker.platform
113+
.saveFile(
114+
dialogTitle: dialogTitle,
115+
fileName: fileName,
116+
initialDirectory: initialDirectory,
117+
lockParentWindow: true,
118+
type: fileType,
119+
allowedExtensions: allowedExtensions,
120+
)
121+
.then((result) {
122+
debugPrint("saveFile() completed");
123+
_path = result;
124+
sendEvent();
125+
});
126+
}
127+
// saveFile
128+
else if (state?.toLowerCase() == "getdirectorypath" && !kIsWeb) {
129+
FilePicker.platform
130+
.getDirectoryPath(
131+
dialogTitle: dialogTitle,
132+
initialDirectory: initialDirectory,
133+
lockParentWindow: true,
134+
)
135+
.then((result) {
136+
debugPrint("getDirectoryPath() completed");
137+
_path = result;
138+
sendEvent();
139+
});
140+
}
141+
_state = state;
142+
}
143+
144+
// upload files
145+
if (_upload != upload && upload != null && _files != null) {
146+
_upload = upload;
147+
uploadFiles(upload, FletAppServices.of(context).ws, pageUri!);
148+
}
149+
150+
return const SizedBox.shrink();
151+
});
152+
}
153+
154+
Future uploadFiles(String filesJson, WebSocketClient ws, Uri pageUri) async {
155+
var uj = json.decode(filesJson);
156+
var uploadFiles = (uj as List).map((u) => FilePickerUploadFile(
157+
name: u["name"], uploadUrl: u["upload_url"], method: u["method"]));
158+
for (var uf in uploadFiles) {
159+
var file = _files!.firstWhereOrNull((f) => f.name == uf.name);
160+
if (file != null) {
161+
try {
162+
await uploadFile(
163+
file, ws, getFullUploadUrl(pageUri, uf.uploadUrl), uf.method);
164+
_files!.remove(file);
165+
} catch (e) {
166+
sendProgress(ws, file.name, null, e.toString());
167+
}
168+
}
169+
}
170+
}
171+
172+
Future uploadFile(PlatformFile file, WebSocketClient ws, String uploadUrl,
173+
String method) async {
174+
final fileReadStream = file.readStream;
175+
if (fileReadStream == null) {
176+
throw Exception('Cannot read file from null stream');
177+
}
178+
debugPrint("Uploading ${file.name}");
179+
final streamedRequest = http.StreamedRequest(method, Uri.parse(uploadUrl))
180+
..headers.addAll({
181+
//'Cache-Control': 'no-cache',
182+
});
183+
streamedRequest.contentLength = file.size;
184+
185+
// send 0%
186+
sendProgress(ws, file.name, 0, null);
187+
188+
double lastSent = 0; // send every 10%
189+
double progress = 0;
190+
int bytesSent = 0;
191+
fileReadStream.listen((chunk) async {
192+
//debugPrint(chunk.length);
193+
streamedRequest.sink.add(chunk);
194+
bytesSent += chunk.length;
195+
progress = bytesSent / file.size;
196+
if (progress >= lastSent) {
197+
lastSent += 0.1;
198+
if (progress != 1.0) {
199+
sendProgress(ws, file.name, progress, null);
200+
}
201+
}
202+
}, onDone: () {
203+
streamedRequest.sink.close();
204+
});
205+
206+
var streamedResponse = await streamedRequest.send();
207+
var response = await http.Response.fromStream(streamedResponse);
208+
if (response.statusCode < 200 || response.statusCode > 204) {
209+
sendProgress(ws, file.name, null,
210+
"Upload endpoint returned code ${response.statusCode}: ${response.body}");
211+
} else {
212+
// send 100%
213+
sendProgress(ws, file.name, progress, null);
214+
}
215+
}
216+
217+
void sendProgress(
218+
WebSocketClient ws, String name, double? progress, String? error) {
219+
ws.pageEventFromWeb(
220+
eventTarget: widget.control.id,
221+
eventName: "upload",
222+
eventData: json.encode(FilePickerUploadProgressEvent(
223+
name: name, progress: progress, error: error)));
224+
}
225+
226+
String getFullUploadUrl(Uri pageUri, String uploadUrl) {
227+
Uri uploadUri = Uri.parse(uploadUrl);
228+
if (!uploadUri.hasAuthority) {
229+
return Uri(
230+
scheme: pageUri.scheme,
231+
host: pageUri.host,
232+
port: pageUri.port,
233+
path: uploadUri.path,
234+
query: uploadUri.query)
235+
.toString();
236+
} else {
237+
return uploadUrl;
238+
}
239+
}
240+
}

package/lib/src/controls/page.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import '../models/control_view_model.dart';
1414
import '../models/controls_view_model.dart';
1515
import '../models/page_media_view_model.dart';
1616
import '../models/routes_view_model.dart';
17-
import '../protocol/keyboard_event_data.dart';
17+
import '../protocol/keyboard_event.dart';
1818
import '../routing/route_parser.dart';
1919
import '../routing/route_state.dart';
2020
import '../routing/router_delegate.dart';
@@ -136,7 +136,7 @@ class _PageControlState extends State<PageControl> {
136136
FletAppServices.of(context).ws.pageEventFromWeb(
137137
eventTarget: "page",
138138
eventName: "keyboard_event",
139-
eventData: json.encode(KeyboardEventData(
139+
eventData: json.encode(KeyboardEvent(
140140
key: k.keyLabel,
141141
isAltPressed: e.isAltPressed,
142142
isControlPressed: e.isControlPressed,

package/lib/src/models/control_type.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ enum ControlType {
1515
dropdown,
1616
dropdownOption,
1717
elevatedButton,
18+
filePicker,
1819
floatingActionButton,
1920
gridView,
2021
icon,

package/lib/src/protocol/container_tap_event_data.dart renamed to package/lib/src/protocol/container_tap_event.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
class ContainerTapEventData {
1+
class ContainerTapEvent {
22
final double localX;
33
final double localY;
44
final double globalX;
55
final double globalY;
66

7-
ContainerTapEventData(
7+
ContainerTapEvent(
88
{required this.localX,
99
required this.localY,
1010
required this.globalX,

0 commit comments

Comments
 (0)