From b23791162fe9285f560b4899e3d8e4040f084ad2 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 7 Nov 2023 14:05:52 -0800 Subject: [PATCH] Global state for dialog-based controls (#2032) * AlertDialog uses global state * DatePicker uses global state * State on the control level * Control-level state Fix #2025, Fix #1807, Fix #1236, Fix #1772 * Fix control tests * Bump fl_chart to 0.64.0 * Fix `scroll_to` with 0 animation duration Fix #1659 * Fix page.width/.height on session start Fix #1960 * Fix Flet version retrieval on non-English environments Fix #1997 --- client/pubspec.lock | 4 +- package/lib/src/controls/alert_dialog.dart | 37 +++-- package/lib/src/controls/audio.dart | 127 +++++++++++------- package/lib/src/controls/create_control.dart | 4 +- package/lib/src/controls/date_picker.dart | 15 ++- package/lib/src/controls/page.dart | 15 ++- .../lib/src/controls/scrollable_control.dart | 38 ++++-- package/lib/src/models/app_state.dart | 10 +- package/lib/src/models/control.dart | 39 +++--- package/lib/src/reducers.dart | 5 + package/lib/src/routing/router_delegate.dart | 1 - package/pubspec.yaml | 2 +- package/test/controls/control_test.dart | 12 +- sdk/python/packages/flet/src/flet/version.py | 14 +- 14 files changed, 195 insertions(+), 128 deletions(-) diff --git a/client/pubspec.lock b/client/pubspec.lock index 06d944cf0..458e29a52 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: fl_chart - sha256: c1e26c7e48496be85104c16c040950b0436674cdf0737f3f6e95511b2529b592 + sha256: "6b9eb2b3017241d05c482c01f668dd05cc909ec9a0114fdd49acd958ff2432fa" url: "https://pub.dev" source: hosted - version: "0.63.0" + version: "0.64.0" flet: dependency: "direct main" description: diff --git a/package/lib/src/controls/alert_dialog.dart b/package/lib/src/controls/alert_dialog.dart index 3eb38d005..4fdea6940 100644 --- a/package/lib/src/controls/alert_dialog.dart +++ b/package/lib/src/controls/alert_dialog.dart @@ -33,8 +33,6 @@ class AlertDialogControl extends StatefulWidget { } class _AlertDialogControlState extends State { - bool _open = false; - Widget _createAlertDialog() { bool disabled = widget.control.isDisabled || widget.parentDisabled; var titleCtrls = @@ -72,7 +70,11 @@ class _AlertDialogControlState extends State { @override Widget build(BuildContext context) { - debugPrint("AlertDialog build: ${widget.control.id}"); + debugPrint("AlertDialog build ($hashCode): ${widget.control.id}"); + + var server = FletAppServices.of(context).server; + + bool lastOpen = widget.control.state["open"] ?? false; return StoreConnector( distinct: true, @@ -82,30 +84,27 @@ class _AlertDialogControlState extends State { var open = widget.control.attrBool("open", false)!; var modal = widget.control.attrBool("modal", false)!; - // var removeCurrentSnackbar = - // widget.control.attrBool("removeCurrentSnackBar", false)!; - debugPrint("Current open state: $_open"); + debugPrint("Current open state: $lastOpen"); debugPrint("New open state: $open"); - if (open && (open != _open)) { + if (open && (open != lastOpen)) { var dialog = _createAlertDialog(); if (dialog is ErrorControl) { return dialog; } - WidgetsBinding.instance.addPostFrameCallback((_) { - // if (removeCurrentSnackbar) { - // ScaffoldMessenger.of(context).removeCurrentSnackBar(); - // } + widget.control.state["open"] = open; + WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( barrierDismissible: !modal, context: context, builder: (context) => _createAlertDialog()).then((value) { - debugPrint("Dialog dismissed: $_open"); - bool shouldDismiss = _open; - _open = false; + lastOpen = widget.control.state["open"] ?? false; + debugPrint("Dialog should be dismissed ($hashCode): $lastOpen"); + bool shouldDismiss = lastOpen; + widget.control.state["open"] = false; if (shouldDismiss) { List> props = [ @@ -113,22 +112,18 @@ class _AlertDialogControlState extends State { ]; dispatch(UpdateControlPropsAction( UpdateControlPropsPayload(props: props))); - FletAppServices.of(context) - .server - .updateControlProps(props: props); - FletAppServices.of(context).server.sendPageEvent( + server.updateControlProps(props: props); + server.sendPageEvent( eventTarget: widget.control.id, eventName: "dismiss", eventData: ""); } }); }); - } else if (open != _open && _open) { + } else if (open != lastOpen && lastOpen) { Navigator.pop(context); } - _open = open; - return widget.nextChild ?? const SizedBox.shrink(); }); } diff --git a/package/lib/src/controls/audio.dart b/package/lib/src/controls/audio.dart index 29237a4ec..b8f8ef75a 100644 --- a/package/lib/src/controls/audio.dart +++ b/package/lib/src/controls/audio.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:audioplayers/audioplayers.dart'; @@ -33,33 +34,37 @@ class AudioControl extends StatefulWidget { } class _AudioControlState extends State { - String _src = ""; - String _srcBase64 = ""; - ReleaseMode? _releaseMode; - double? _volume; - double? _balance; - double? _playbackRate; + AudioPlayer? player; void Function(Duration)? _onDurationChanged; void Function(PlayerState)? _onStateChanged; void Function(int)? _onPositionChanged; Duration? _duration; int _position = -1; void Function()? _onSeekComplete; - late final AudioPlayer player; + StreamSubscription? _onDurationChangedSubscription; + StreamSubscription? _onStateChangedSubscription; + StreamSubscription? _onPositionChangedSubscription; + StreamSubscription? _onSeekCompleteSubscription; FletServer? _server; @override void initState() { - super.initState(); - player = AudioPlayer(); - player.onDurationChanged.listen((duration) { + debugPrint("Audio.initState($hashCode)"); + player = widget.control.state["player"]; + if (player == null) { + player = AudioPlayer(); + player = widget.control.state["player"] = player; + } + _onDurationChangedSubscription = + player?.onDurationChanged.listen((duration) { _onDurationChanged?.call(duration); _duration = duration; }); - player.onPlayerStateChanged.listen((state) { + _onStateChangedSubscription = player?.onPlayerStateChanged.listen((state) { _onStateChanged?.call(state); }); - player.onPositionChanged.listen((position) { + _onPositionChangedSubscription = + player?.onPositionChanged.listen((position) { int posMs = (position.inMilliseconds / 1000).round() * 1000; if (posMs != _position) { _position = posMs; @@ -70,21 +75,35 @@ class _AudioControlState extends State { } _onPositionChanged?.call(_position); }); - player.onSeekComplete.listen((event) { + _onSeekCompleteSubscription = player?.onSeekComplete.listen((event) { _onSeekComplete?.call(); }); + + widget.control.onRemove.clear(); + widget.control.onRemove.add(_onRemove); + super.initState(); + } + + void _onRemove() { + debugPrint("Audio.remove($hashCode)"); + widget.control.state["player"]?.dispose(); + _server?.controlInvokeMethods.remove(widget.control.id); } @override void deactivate() { - _server?.controlInvokeMethods.remove(widget.control.id); - player.dispose(); + debugPrint("Audio.deactivate($hashCode)"); + _onDurationChangedSubscription?.cancel(); + _onStateChangedSubscription?.cancel(); + _onPositionChangedSubscription?.cancel(); + _onSeekCompleteSubscription?.cancel(); super.deactivate(); } @override Widget build(BuildContext context) { - debugPrint("Audio build: ${widget.control.id}"); + debugPrint( + "Audio build: ${widget.control.id} (${widget.control.hashCode})"); var src = widget.control.attrString("src", "")!; var srcBase64 = widget.control.attrString("srcBase64", "")!; @@ -104,6 +123,13 @@ class _AudioControlState extends State { var server = FletAppServices.of(context).server; + final String prevSrc = widget.control.state["src"] ?? ""; + final String prevSrcBase64 = widget.control.state["srcBase64"] ?? ""; + final ReleaseMode? prevReleaseMode = widget.control.state["releaseMode"]; + final double? prevVolume = widget.control.state["volume"]; + final double? prevBalance = widget.control.state["balance"]; + final double? prevPlaybackRate = widget.control.state["playbackRate"]; + return StoreConnector( distinct: true, converter: (store) => PageArgsModel.fromStore(store), @@ -116,6 +142,7 @@ class _AudioControlState extends State { }; _onStateChanged = (state) { + debugPrint("Audio($hashCode) - state_changed: ${state.name}"); server.sendPageEvent( eventTarget: widget.control.id, eventName: "state_changed", @@ -139,64 +166,74 @@ class _AudioControlState extends State { }; () async { + debugPrint("Audio ($hashCode) src=$src, prevSrc=$prevSrc"); + debugPrint( + "Audio ($hashCode) srcBase64=$srcBase64, prevSrcBase64=$prevSrcBase64"); + bool srcChanged = false; - if (src != "" && src != _src) { - _src = src; + if (src != "" && src != prevSrc) { + widget.control.state["src"] = src; srcChanged = true; // URL or file? var assetSrc = getAssetSrc(src, pageArgs.pageUri!, pageArgs.assetsDir); if (assetSrc.isFile) { - await player.setSourceDeviceFile(assetSrc.path); + await player?.setSourceDeviceFile(assetSrc.path); } else { - await player.setSourceUrl(assetSrc.path); + await player?.setSourceUrl(assetSrc.path); } - } else if (srcBase64 != "" && srcBase64 != _srcBase64) { - _srcBase64 = srcBase64; + } else if (srcBase64 != "" && srcBase64 != prevSrcBase64) { + widget.control.state["srcBase64"] = srcBase64; srcChanged = true; - await player.setSourceBytes(base64Decode(srcBase64)); + await player?.setSourceBytes(base64Decode(srcBase64)); } if (srcChanged) { + debugPrint("Audio.srcChanged!"); server.sendPageEvent( eventTarget: widget.control.id, eventName: "loaded", eventData: ""); } - if (releaseMode != null && releaseMode != _releaseMode) { - _releaseMode = releaseMode; - await player.setReleaseMode(releaseMode); + if (releaseMode != null && releaseMode != prevReleaseMode) { + debugPrint("Audio.setReleaseMode($releaseMode)"); + widget.control.state["releaseMode"] = releaseMode; + await player?.setReleaseMode(releaseMode); } if (volume != null && - volume != _volume && + volume != prevVolume && volume >= 0 && volume <= 1) { - _volume = volume; - await player.setVolume(volume); + widget.control.state["volume"] = volume; + debugPrint("Audio.setVolume($volume)"); + await player?.setVolume(volume); } if (playbackRate != null && - playbackRate != _playbackRate && + playbackRate != prevPlaybackRate && playbackRate >= 0 && playbackRate <= 2) { - _playbackRate = playbackRate; - await player.setPlaybackRate(playbackRate); + widget.control.state["playbackRate"] = playbackRate; + debugPrint("Audio.setPlaybackRate($playbackRate)"); + await player?.setPlaybackRate(playbackRate); } if (!kIsWeb && balance != null && - balance != _balance && + balance != prevBalance && balance >= -1 && balance <= 1) { - _balance = balance; - await player.setBalance(balance); + widget.control.state["balance"] = balance; + debugPrint("Audio.setBalance($balance)"); + await player?.setBalance(balance); } if (srcChanged && autoplay) { - await player.resume(); + debugPrint("Audio.resume($srcChanged, $autoplay)"); + await player?.resume(); } _server = server; @@ -204,28 +241,28 @@ class _AudioControlState extends State { (methodName, args) async { switch (methodName) { case "play": - await player.seek(const Duration(milliseconds: 0)); - await player.resume(); + await player?.seek(const Duration(milliseconds: 0)); + await player?.resume(); break; case "resume": - await player.resume(); + await player?.resume(); break; case "pause": - await player.pause(); + await player?.pause(); break; case "release": - await player.release(); + await player?.release(); break; case "seek": - await player.seek(Duration( + await player?.seek(Duration( milliseconds: int.tryParse(args["position"] ?? "") ?? 0)); break; case "get_duration": - return (await player.getDuration()) + return (await player?.getDuration()) ?.inMilliseconds .toString(); case "get_current_position": - return (await player.getCurrentPosition()) + return (await player?.getCurrentPosition()) ?.inMilliseconds .toString(); } @@ -233,7 +270,7 @@ class _AudioControlState extends State { }; }(); - return widget.nextChild ?? const SizedBox.shrink(); + return const SizedBox.shrink(); }); } } diff --git a/package/lib/src/controls/create_control.dart b/package/lib/src/controls/create_control.dart index 084070edf..2259166d2 100644 --- a/package/lib/src/controls/create_control.dart +++ b/package/lib/src/controls/create_control.dart @@ -15,6 +15,7 @@ import '../utils/transforms.dart'; import 'alert_dialog.dart'; import 'animated_switcher.dart'; import 'audio.dart'; +import 'badge.dart'; import 'banner.dart'; import 'barchart.dart'; import 'bottom_sheet.dart'; @@ -57,6 +58,7 @@ import 'progress_bar.dart'; import 'progress_ring.dart'; import 'radio.dart'; import 'radio_group.dart'; +import 'range_slider.dart'; import 'responsive_row.dart'; import 'row.dart'; import 'safe_area.dart'; @@ -75,8 +77,6 @@ import 'tooltip.dart'; import 'transparent_pointer.dart'; import 'vertical_divider.dart'; import 'window_drag_area.dart'; -import 'range_slider.dart'; -import 'badge.dart'; Widget createControl(Control? parent, String id, bool parentDisabled, {Widget? nextChild}) { diff --git a/package/lib/src/controls/date_picker.dart b/package/lib/src/controls/date_picker.dart index d65c07e14..63063becb 100644 --- a/package/lib/src/controls/date_picker.dart +++ b/package/lib/src/controls/date_picker.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; + import '../actions.dart'; import '../flet_app_services.dart'; import '../models/control.dart'; import '../protocol/update_control_props_payload.dart'; -import 'form_field.dart'; import '../utils/icons.dart'; +import 'form_field.dart'; class DatePickerControl extends StatefulWidget { final Control? parent; @@ -27,11 +28,12 @@ class DatePickerControl extends StatefulWidget { } class _DatePickerControlState extends State { - bool _open = false; - @override Widget build(BuildContext context) { debugPrint("DatePicker build: ${widget.control.id}"); + + bool lastOpen = widget.control.state["open"] ?? false; + var open = widget.control.attrBool("open", false)!; DateTime? value = widget.control.attrDateTime("value"); DateTime? firstDate = widget.control.attrDateTime("firstDate"); @@ -83,6 +85,7 @@ class _DatePickerControlState extends State { stringValue = dateValue.toIso8601String(); eventName = "change"; } + widget.control.state["open"] = false; List> props = [ {"i": widget.control.id, "value": stringValue, "open": "false"} ]; @@ -129,7 +132,9 @@ class _DatePickerControlState extends State { return dialog; } - if (open && !_open) { + if (open && (open != lastOpen)) { + widget.control.state["open"] = open; + WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( context: context, @@ -139,8 +144,6 @@ class _DatePickerControlState extends State { }); }); } - - _open = open; return const SizedBox.shrink(); } } diff --git a/package/lib/src/controls/page.dart b/package/lib/src/controls/page.dart index 959b63b40..bbdc23234 100644 --- a/package/lib/src/controls/page.dart +++ b/package/lib/src/controls/page.dart @@ -482,11 +482,16 @@ class _PageControlState extends State { if (routesView.views.isEmpty) { pages.add(FadeTransitionPage( child: hideLoadingPage - ? const Scaffold() - : LoadingPage( - isLoading: routesView.isLoading, - message: routesView.error, - ))); + ? const Scaffold( + body: PageMedia(), + ) + : Stack(children: [ + const PageMedia(), + LoadingPage( + isLoading: routesView.isLoading, + message: routesView.error, + ) + ]))); } else { Widget? loadingPage; // offstage diff --git a/package/lib/src/controls/scrollable_control.dart b/package/lib/src/controls/scrollable_control.dart index 3193bcd6c..962de6b9c 100644 --- a/package/lib/src/controls/scrollable_control.dart +++ b/package/lib/src/controls/scrollable_control.dart @@ -102,9 +102,7 @@ class _ScrollableControlState extends State { var params = Map.from(mj["p"] as Map); if (name == "scroll_to") { - var duration = params["duration"] != null - ? Duration(milliseconds: parseInt(params["duration"])) - : Duration.zero; + var duration = parseInt(params["duration"]); var curve = params["curve"] != null ? parseCurve(params["curve"] as String) : Curves.ease; @@ -114,7 +112,11 @@ class _ScrollableControlState extends State { if (key != null) { var ctx = key.currentContext; if (ctx != null) { - Scrollable.ensureVisible(ctx, duration: duration, curve: curve); + Scrollable.ensureVisible(ctx, + duration: duration > 0 + ? Duration(milliseconds: duration) + : Duration.zero, + curve: curve); } } }); @@ -124,21 +126,29 @@ class _ScrollableControlState extends State { if (offset < 0) { offset = _controller.position.maxScrollExtent + offset + 1; } - _controller.animateTo( - offset, - duration: duration, - curve: curve, - ); + if (duration < 1) { + _controller.jumpTo(offset); + } else { + _controller.animateTo( + offset, + duration: Duration(milliseconds: duration), + curve: curve, + ); + } }); } else if (params["delta"] != null) { WidgetsBinding.instance.addPostFrameCallback((_) { var delta = parseDouble(params["delta"]); var offset = _controller.position.pixels + delta; - _controller.animateTo( - offset, - duration: duration, - curve: curve, - ); + if (duration < 1) { + _controller.jumpTo(offset); + } else { + _controller.animateTo( + offset, + duration: Duration(milliseconds: duration), + curve: curve, + ); + } }); } } diff --git a/package/lib/src/models/app_state.dart b/package/lib/src/models/app_state.dart index 25fa9b245..16e462382 100644 --- a/package/lib/src/models/app_state.dart +++ b/package/lib/src/models/app_state.dart @@ -39,7 +39,7 @@ class AppState extends Equatable { required this.displayBrightness, required this.controls}); - factory AppState.initial() => const AppState( + factory AppState.initial() => AppState( pageUri: null, assetsDir: "", route: "", @@ -49,8 +49,8 @@ class AppState extends Equatable { isRegistered: false, reconnectDelayMs: 0, error: "", - size: Size(0, 0), - sizeBreakpoints: { + size: const Size(0, 0), + sizeBreakpoints: const { "xs": 0, "sm": 576, "md": 768, @@ -65,8 +65,8 @@ class AppState extends Equatable { pid: "", type: "page", name: "", - childIds: [], - attrs: {}) + childIds: const [], + attrs: const {}) }); AppState copyWith( diff --git a/package/lib/src/models/control.dart b/package/lib/src/models/control.dart index 52fd2a8df..9de0569ec 100644 --- a/package/lib/src/models/control.dart +++ b/package/lib/src/models/control.dart @@ -9,8 +9,10 @@ class Control extends Equatable { final String? name; final List childIds; final Map attrs; + final Map state = {}; + final Set onRemove = {}; - const Control( + Control( {required this.id, required this.pid, required this.type, @@ -46,7 +48,7 @@ class Control extends Equatable { bool get isNonVisual { return [ //"alertdialog", - "audio", + //"audio", "banner", //"bottomsheet", "clipboard", @@ -95,19 +97,26 @@ class Control extends Equatable { } Control copyWith( - {String? id, - String? pid, - String? type, - String? name, - List? childIds, - Map? attrs}) => - Control( - id: id ?? this.id, - pid: pid ?? this.pid, - type: type ?? this.type, - name: name ?? this.name, - childIds: childIds ?? this.childIds, - attrs: attrs ?? this.attrs); + {String? id, + String? pid, + String? type, + String? name, + List? childIds, + Map? attrs, + Map? state}) { + Control c = Control( + id: id ?? this.id, + pid: pid ?? this.pid, + type: type ?? this.type, + name: name ?? this.name, + childIds: childIds ?? this.childIds, + attrs: attrs ?? this.attrs); + for (var element in this.state.entries) { + c.state[element.key] = element.value; + } + c.onRemove.addAll(onRemove); + return c; + } @override List get props => [id, pid, type, name, childIds, attrs]; diff --git a/package/lib/src/reducers.dart b/package/lib/src/reducers.dart index 0e14b2069..748384d9c 100644 --- a/package/lib/src/reducers.dart +++ b/package/lib/src/reducers.dart @@ -469,6 +469,11 @@ removeControls(Map controls, List ids) { } // delete control itself + if (ctrl != null) { + for (var handler in ctrl.onRemove) { + handler(); + } + } controls.remove(id); // remove control's ID from parent's children collection diff --git a/package/lib/src/routing/router_delegate.dart b/package/lib/src/routing/router_delegate.dart index 89a163bd7..9b389e6ec 100644 --- a/package/lib/src/routing/router_delegate.dart +++ b/package/lib/src/routing/router_delegate.dart @@ -30,7 +30,6 @@ class SimpleRouterDelegate extends RouterDelegate @override String get currentConfiguration { - debugPrint("currentConfiguration"); return routeState.route; } diff --git a/package/pubspec.yaml b/package/pubspec.yaml index 61384efd8..b904eecc6 100644 --- a/package/pubspec.yaml +++ b/package/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: shake: ^2.2.0 path: ^1.8.2 js: ^0.6.5 - fl_chart: ^0.63.0 + fl_chart: ^0.64.0 dev_dependencies: flutter_test: diff --git a/package/test/controls/control_test.dart b/package/test/controls/control_test.dart index da08021f5..ae83a5911 100644 --- a/package/test/controls/control_test.dart +++ b/package/test/controls/control_test.dart @@ -5,21 +5,21 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test("Two controls are equal", () { - Control c1 = const Control( + Control c1 = Control( id: "i1", pid: "p1", type: "stack", name: null, - childIds: ["txt1", "btn1"], - attrs: {"text": "Hello!", "width": "200"}); + childIds: const ["txt1", "btn1"], + attrs: const {"text": "Hello!", "width": "200"}); - Control c2 = const Control( + Control c2 = Control( id: "i1", pid: "p1", type: "stack", name: null, - childIds: ["txt1", "btn1"], - attrs: {"width": "200", "text": "Hello!"}); + childIds: const ["txt1", "btn1"], + attrs: const {"width": "200", "text": "Hello!"}); expect(c1 == c2, true); }); diff --git a/sdk/python/packages/flet/src/flet/version.py b/sdk/python/packages/flet/src/flet/version.py index 5ccf6ed2f..cd6a50383 100644 --- a/sdk/python/packages/flet/src/flet/version.py +++ b/sdk/python/packages/flet/src/flet/version.py @@ -16,11 +16,15 @@ def update_version(): """Return the current version or default.""" working = Path().absolute() os.chdir(Path(flet.__file__).absolute().parent) - in_repo = which("git.exe" if is_windows() else "git") and sp.run( - ["git", "status"], - capture_output=True, - text=True, - ).stdout.startswith("On branch ") + in_repo = ( + which("git.exe" if is_windows() else "git") + and sp.run( + ["git", "status"], + capture_output=True, + text=True, + ).returncode + == 0 + ) if in_repo: # NOTE: this may break if there is a tag name starting with