From 51b7df02e7c1fa5d1b7c0bea550a7f7d7e3f3e12 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 19 Jan 2025 20:36:17 +0800 Subject: [PATCH] Improve ui api --- assets/init.js | 34 ++++++- lib/components/js_ui.dart | 183 ++++++++++++++++++++++++++++++++++ lib/foundation/js_engine.dart | 53 +--------- 3 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 lib/components/js_ui.dart diff --git a/assets/init.js b/assets/init.js index 5e7b234..ef6b393 100644 --- a/assets/init.js +++ b/assets/init.js @@ -1205,6 +1205,10 @@ class Image { } } +/** + * UI related apis + * @since 1.2.0 + */ let UI = { /** * Show a message @@ -1222,7 +1226,8 @@ let UI = { * Show a dialog. Any action will close the dialog. * @param title {string} * @param content {string} - * @param actions {{text:string, callback: () => void}[]} + * @param actions {{text:string, callback: () => void | Promise, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved. + * @since 1.2.1 */ showDialog: (title, content, actions) => { sendMessage({ @@ -1245,4 +1250,31 @@ let UI = { url: url, }) }, + + /** + * Show a loading dialog. + * @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user. + * @returns {number} - A number that can be used to cancel the loading dialog. + * @since 1.2.1 + */ + showLoading: (onCancel) => { + return sendMessage({ + method: 'UI', + function: 'showLoading', + onCancel: onCancel + }) + }, + + /** + * Cancel a loading dialog. + * @param id {number} - returned by [showLoading] + * @since 1.2.1 + */ + cancelLoading: (id) => { + sendMessage({ + method: 'UI', + function: 'cancelLoading', + id: id + }) + } } \ No newline at end of file diff --git a/lib/components/js_ui.dart b/lib/components/js_ui.dart new file mode 100644 index 0000000..4da74cf --- /dev/null +++ b/lib/components/js_ui.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_qjs/flutter_qjs.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/js_engine.dart'; + +import 'components.dart'; + +mixin class JsUiApi { + final Map _loadingDialogControllers = {}; + + dynamic handleUIMessage(Map message) { + switch (message['function']) { + case 'showMessage': + var m = message['message']; + if (m.toString().isNotEmpty) { + App.rootContext.showMessage(message: m.toString()); + } + case 'showDialog': + _showDialog(message); + case 'launchUrl': + var url = message['url']; + if (url.toString().isNotEmpty) { + launchUrlString(url.toString()); + } + case 'showLoading': + var onCancel = message['onCancel']; + if (onCancel != null && onCancel is! JSInvokable) { + return; + } + return showLoading(onCancel); + case 'cancelLoading': + var id = message['id']; + if (id is int) { + cancelLoading(id); + } + } + } + + void _showDialog(Map message) { + BuildContext? dialogContext; + var title = message['title']; + var content = message['content']; + var actions = []; + for (var action in message['actions']) { + if (action['callback'] is! JSInvokable) { + continue; + } + var callback = action['callback'] as JSInvokable; + // [message] will be released after the method call, causing the action to be invalid, so we need to duplicate it + callback.dup(); + var text = action['text'].toString(); + var style = (action['style'] ?? 'text').toString(); + actions.add(_JSCallbackButton( + text: text, + callback: JSAutoFreeFunction(callback), + style: style, + onCallbackFinished: () { + dialogContext?.pop(); + }, + )); + } + if (actions.isEmpty) { + actions.add(TextButton( + onPressed: () { + dialogContext?.pop(); + }, + child: Text('OK'), + )); + } + showDialog( + context: App.rootContext, + builder: (context) { + dialogContext = context; + return ContentDialog( + title: title, + content: Text(content).paddingHorizontal(16), + actions: actions, + ); + }, + ).then((value) { + dialogContext = null; + }); + } + + int showLoading(JSInvokable? onCancel) { + onCancel?.dup(); + var func = onCancel == null ? null : JSAutoFreeFunction(onCancel); + var controller = showLoadingDialog( + App.rootContext, + barrierDismissible: onCancel != null, + allowCancel: onCancel != null, + onCancel: onCancel == null ? null : () { + func?.call([]); + }, + ); + var i = 0; + while (_loadingDialogControllers.containsKey(i)) { + i++; + } + _loadingDialogControllers[i] = controller; + return i; + } + + void cancelLoading(int id) { + var controller = _loadingDialogControllers.remove(id); + controller?.close(); + } +} + +class _JSCallbackButton extends StatefulWidget { + const _JSCallbackButton({ + required this.text, + required this.callback, + required this.style, + this.onCallbackFinished, + }); + + final JSAutoFreeFunction callback; + + final String text; + + final String style; + + final void Function()? onCallbackFinished; + + @override + State<_JSCallbackButton> createState() => _JSCallbackButtonState(); +} + +class _JSCallbackButtonState extends State<_JSCallbackButton> { + bool isLoading = false; + + void onClick() async { + if (isLoading) { + return; + } + var res = widget.callback.call([]); + if (res is Future) { + setState(() { + isLoading = true; + }); + await res; + setState(() { + isLoading = false; + }); + } + widget.onCallbackFinished?.call(); + } + + @override + Widget build(BuildContext context) { + return switch (widget.style) { + "filled" => FilledButton( + onPressed: onClick, + child: isLoading + ? CircularProgressIndicator(strokeWidth: 1.4) + .fixWidth(18) + .fixHeight(18) + : Text(widget.text), + ), + "danger" => FilledButton( + onPressed: onClick, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(context.colorScheme.error), + ), + child: isLoading + ? CircularProgressIndicator(strokeWidth: 1.4) + .fixWidth(18) + .fixHeight(18) + : Text(widget.text), + ), + _ => TextButton( + onPressed: onClick, + child: isLoading + ? CircularProgressIndicator(strokeWidth: 1.4) + .fixWidth(18) + .fixHeight(18) + : Text(widget.text), + ), + }; + } +} diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index fd87135..4cc59d8 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:dio/io.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; @@ -20,9 +19,8 @@ import 'package:pointycastle/block/modes/cbc.dart'; import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ofb.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:uuid/uuid.dart'; -import 'package:venera/components/components.dart'; +import 'package:venera/components/js_ui.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; @@ -42,7 +40,7 @@ class JavaScriptRuntimeException implements Exception { } } -class JsEngine with _JSEngineApi, _JsUiApi { +class JsEngine with _JSEngineApi, JsUiApi { factory JsEngine() => _cache ?? (_cache = JsEngine._create()); static JsEngine? _cache; @@ -156,7 +154,7 @@ class JsEngine with _JSEngineApi, _JsUiApi { case "delay": return Future.delayed(Duration(milliseconds: message["time"])); case "UI": - handleUIMessage(Map.from(message)); + return handleUIMessage(Map.from(message)); } } return null; @@ -690,48 +688,3 @@ class JSAutoFreeFunction { func.free(); }); } - -mixin class _JsUiApi { - void handleUIMessage(Map message) { - switch (message['function']) { - case 'showMessage': - var m = message['message']; - if (m.toString().isNotEmpty) { - App.rootContext.showMessage(message: m.toString()); - } - case 'showDialog': - _showDialog(message); - case 'launchUrl': - var url = message['url']; - if (url.toString().isNotEmpty) { - launchUrlString(url.toString()); - } - } - } - - void _showDialog(Map message) { - var title = message['title']; - var content = message['content']; - var actions = {}; - for (var action in message['actions']) { - // [message] will be released after the method call, causing the action to be invalid, so we need to duplicate it - (action['callback'] as JSInvokable).dup(); - actions[action['text']] = JSAutoFreeFunction(action['callback']); - } - showDialog(context: App.rootContext, builder: (context) { - return ContentDialog( - title: title, - content: Text(content).paddingHorizontal(16), - actions: actions.entries.map((entry) { - return TextButton( - onPressed: () { - entry.value.call([]); - context.pop(); - }, - child: Text(entry.key), - ); - }).toList(), - ); - }); - } -}