diff --git a/.android/gradle.properties b/.android/gradle.properties index 94adc3a3f..598d13fee 100644 --- a/.android/gradle.properties +++ b/.android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/assets/schema/ensemble_schema.json b/assets/schema/ensemble_schema.json index 79d39c48c..dd265fb55 100644 --- a/assets/schema/ensemble_schema.json +++ b/assets/schema/ensemble_schema.json @@ -166,6 +166,10 @@ "type": "boolean", "description": "Specify if the content of this screen is scrollable with a global scrollbar. Using this also allow you to customize the scrolling experience of the header." }, + "unfocus": { + "type": "boolean", + "description": "Specify if the keyboard should dismissed automatically when tapping in the non interactive widgets. Default (false)" + }, "showNavigationIcon": { "type": "boolean", "description": "For a screen with header, the App will automatically show the Menu, Back, or Close icon (for modal screen) before the title. On modal screen without the header, the Close icon will be shown. Set this flag to false if you wish to hide the icons and handle the navigation yourself." @@ -460,7 +464,7 @@ }, "cssStyles": { "type": "array", - "oneOf": [ + "items": [ { "type": "object", "properties": { @@ -1562,6 +1566,10 @@ "type": "boolean", "description": "enable the toggling between plain and obscure text." }, + "toolbarDone": { + "type": "boolean", + "description": "enable the toolbar with done button in the TextInput. (defaults to False)" + }, "enableClearText": { "type": "boolean", "description": "It enables the default suffix clear icon button for the text input field to clear the values. Default (false)" @@ -2649,6 +2657,10 @@ "type": "string", "description": "A unique identifier for this widget" }, + "scrollController": { + "type": "string", + "description": "For setting the scrollbehaviour" + }, "onItemTap": { "$ref": "#/$defs/Action-payload", "description": "Call Ensemble's built-in functions or execute code when tapping on an item in the list." @@ -3062,6 +3074,10 @@ "type": "string", "description": "A unique identifier for this widget" }, + "controller" :{ + "type": "string", + "description": "For setting the scrollbehaviour" + }, "children": { "$ref": "#/$defs/Widgets" }, @@ -5076,6 +5092,10 @@ "type": "boolean", "description": "Mark this item as a floating icon" }, + "switchScreen": { + "type": "boolean", + "description": "Disable the screen switching when clicking the bottom nav bar item. Default (True)" + }, "floatingMargin": { "$ref": "#/$defs/Margin-payload", "description": "The margin around the floating." @@ -5887,13 +5907,13 @@ } }, { - "title": "Youtube", + "title": "YouTube", "required": [ - "Youtube" + "YouTube" ], "properties": { - "Youtube" : { - "$ref" : "#/$defs/Youtube-payload" + "YouTube" : { + "$ref" : "#/$defs/YouTube-payload" } } }, @@ -6546,60 +6566,67 @@ } } }, - "Youtube-payload":{ + "YouTube-payload":{ "type" : "object", "properties" : { "id" : { "type" : "string", "description": "The unique identifier for this widget" }, + "styles": { + "$ref": "#/$defs/boxStyles" + }, "url" : { "type": "string", - "description": "The URL source to the youtube video" + "description": "The URL source to the youTube video" }, "aspectRatio" : { - "type": "double", + "type": "number", "description" : "Video aspect ratio" }, "autoplay": { - "type" : "bool", + "type" : "boolean", "description" : "Automatically start the video when player is loaded. (default False)" }, "videoList" : { - "type" : "array", + "type" : "object", "description" : "List of videos to be played within a single player." }, "startSeconds": { - "type" : "double", + "type" : "number", "description" : "specifies the time from which the first video in the list(or single video) should start playing" }, "endSeconds": { - "type": "double", + "type": "number", "description": "Ends the video after the certain number of seconds (works with Single video)" }, "showAnnotations" : { - "type" : "bool", + "type" : "boolean", "description" : "Showing the annotations of the video" }, "playbackRate" : { - "type": "double", + "type": "number", "description": "For changing the speed at which the video is displayed" }, "showControls": { - "type" : "bool", + "type" : "boolean", "description" : "For showing the controls on the video within the player(Like in youtube)" }, "showFullScreenButton": { - "type" : "bool", + "type" : "boolean", "description" : "To show the fullscreen button of the video" }, "enableCaptions" : { - "type": "bool", + "type": "boolean", "description" : "To enable any captions in the video(default language of caption is English)" }, "volume": { "type": "integer", "description" : "Changes the volume. (max = 100, min = 0)" + }, + "videoPosition": { + "type": "boolean", + "description": "To add an indicator for the amount of video that is completed" } } }, diff --git a/lib/action/action_invokable.dart b/lib/action/action_invokable.dart index 8b1f95367..22e2ba5f3 100644 --- a/lib/action/action_invokable.dart +++ b/lib/action/action_invokable.dart @@ -1,4 +1,3 @@ -import 'package:ensemble/action/call_external_method.dart'; import 'package:ensemble/framework/action.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/scope.dart'; @@ -22,6 +21,9 @@ abstract class ActionInvokable with Invokable { ActionType.copyToClipboard, ActionType.getDeviceToken, ActionType.getPhoneContacts, + ActionType.showBottomModal, + ActionType.dismissBottomModal, + ActionType.showDialog, ]); } diff --git a/lib/action/bottom_modal_action.dart b/lib/action/bottom_modal_action.dart index 72063baa6..0df9206fd 100644 --- a/lib/action/bottom_modal_action.dart +++ b/lib/action/bottom_modal_action.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:ensemble/framework/action.dart'; import 'package:ensemble/framework/data_context.dart'; import 'package:ensemble/framework/error_handling.dart'; @@ -9,9 +7,7 @@ import 'package:ensemble/framework/view/context_scope_widget.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:yaml/yaml.dart'; /// open a Modal Bottom Sheet class ShowBottomModalAction extends EnsembleAction { @@ -68,15 +64,19 @@ class ShowBottomModalAction extends EnsembleAction { if (widget != null) { showModalBottomSheet( - context: context, - backgroundColor: _backgroundColor(scopeManager), - barrierColor: _barrierColor(scopeManager), - isScrollControlled: true, - enableDrag: _enableDrag(scopeManager), - showDragHandle: _enableDragHandler(scopeManager), - builder: (modalContext) => - ContextScopeWidget(rootContext: modalContext, child: widget!)) - .then((payload) { + context: context, + backgroundColor: _backgroundColor(scopeManager), + barrierColor: _barrierColor(scopeManager), + isScrollControlled: true, + enableDrag: _enableDrag(scopeManager), + showDragHandle: _enableDragHandler(scopeManager), + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: ContextScopeWidget(rootContext: context, child: widget!), + ), + ).then((payload) { if (onDismiss != null) { return ScreenController().executeActionWithScope( context, scopeManager, onDismiss!, @@ -106,6 +106,6 @@ class DismissBottomModalAction extends EnsembleAction { return Navigator.maybePop( bottomModalContext, scopeManager.dataContext.eval(payload)); } - return Future.value(null); + return Navigator.maybePop(context, scopeManager.dataContext.eval(payload)); } } diff --git a/lib/ensemble_app.dart b/lib/ensemble_app.dart index 242d1d476..6e87d8a1b 100644 --- a/lib/ensemble_app.dart +++ b/lib/ensemble_app.dart @@ -117,33 +117,12 @@ class EnsembleAppState extends State with WidgetsBindingObserver { Workmanager().initialize(callbackDispatcher, isInDebugMode: false); initDeepLink(AppLifecycleState.resumed); } - - WidgetsBinding.instance.addPostFrameCallback((_) async { - executeCallbacks(); - }); } @override void didChangeAppLifecycleState(AppLifecycleState state) async { super.didChangeAppLifecycleState(state); initDeepLink(state); - if (state == AppLifecycleState.resumed) { - executeCallbacks(); - } - } - - Future executeCallbacks() async { - final callbacks = List.from(Ensemble().getCallbacksAfterInitialization()); - - callbacks.asMap().forEach((index, function) async { - // Removing a method and getting the function with index to execute it - Ensemble().getCallbacksAfterInitialization().remove(function); - try { - await Function.apply(function, null); - } catch (e) { - print('Failed to execute a method: $e'); - } - }); } void initDeepLink(AppLifecycleState state) { diff --git a/lib/framework/action.dart b/lib/framework/action.dart index f51235012..e2a813c6d 100644 --- a/lib/framework/action.dart +++ b/lib/framework/action.dart @@ -15,7 +15,6 @@ import 'package:ensemble/framework/extensions.dart'; import 'package:ensemble/framework/keychain_manager.dart'; import 'package:ensemble/framework/permissions_manager.dart'; import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/view/bottom_nav_page_group.dart'; import 'package:ensemble/framework/view/page_group.dart'; import 'package:ensemble/framework/widget/view_util.dart'; import 'package:ensemble/receive_intent_manager.dart'; @@ -67,17 +66,20 @@ class ShowDialogAction extends EnsembleAction { final Map? options; final EnsembleAction? onDialogDismiss; - factory ShowDialogAction.fromYaml({Invokable? initiator, Map? payload}) { + factory ShowDialogAction.from({Invokable? initiator, Map? payload}) { if (payload == null || payload['widget'] == null) { throw LanguageError( "${ActionType.showDialog.name} requires the 'widget' for the Dialog's content."); } return ShowDialogAction( - initiator: initiator, - widget: payload['widget'], - //inputs: Utils.getMap(payload["inputs"]), - options: Utils.getMap(payload['options']), - onDialogDismiss: EnsembleAction.fromYaml(payload['onDialogDismiss'])); + initiator: initiator, + widget: payload['widget'], + //inputs: Utils.getMap(payload["inputs"]), + options: Utils.getMap(payload['options']), + onDialogDismiss: payload['onDialogDismiss'] == null + ? null + : EnsembleAction.fromYaml(payload['onDialogDismiss']), + ); } } @@ -1006,7 +1008,7 @@ abstract class EnsembleAction { } else if (actionType == ActionType.openCamera) { return ShowCameraAction.fromYaml(initiator: initiator, payload: payload); } else if (actionType == ActionType.showDialog) { - return ShowDialogAction.fromYaml(initiator: initiator, payload: payload); + return ShowDialogAction.from(initiator: initiator, payload: payload); } else if (actionType == ActionType.closeAllDialogs) { return CloseAllDialogsAction(); } else if (actionType == ActionType.startTimer) { diff --git a/lib/framework/data_context.dart b/lib/framework/data_context.dart index 1a6eaf3c6..8cb063f08 100644 --- a/lib/framework/data_context.dart +++ b/lib/framework/data_context.dart @@ -1,17 +1,14 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:developer'; import 'dart:io' as io; import 'dart:ui'; import 'package:ensemble/action/action_invokable.dart'; -import 'package:ensemble/action/call_external_method.dart'; +import 'package:ensemble/action/bottom_modal_action.dart'; import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/action/invoke_api_action.dart'; +import 'package:ensemble/action/misc_action.dart'; import 'package:ensemble/action/navigation_action.dart'; -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/ensemble_app.dart'; import 'package:ensemble/framework/all_countries.dart'; -import 'package:ensemble/framework/bindings.dart'; import 'package:ensemble/framework/config.dart'; import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/error_handling.dart'; @@ -365,6 +362,20 @@ class NativeInvokable extends ActionInvokable { final scope = ScreenController().getScopeManager(buildContext); callNativeMethod(buildContext, scope, inputs); }, + ActionType.showBottomModal.name: (inputs) => + ScreenController().executeAction( + buildContext, + ShowBottomModalAction.from(payload: inputs), + ), + ActionType.dismissBottomModal.name: (inputs) => + ScreenController().executeAction( + buildContext, + DismissBottomModalAction.from(payload: inputs), + ), + ActionType.showDialog.name: (inputs) => ScreenController() + .executeAction(buildContext, ShowDialogAction.from(payload: inputs)), + ActionType.rateApp.name: (inputs) => ScreenController() + .executeAction(buildContext, RateAppAction.from(payload: inputs)), 'connectSocket': (String socketName, Map? inputs) { connectSocket(buildContext, socketName, inputs: inputs); }, diff --git a/lib/framework/menu.dart b/lib/framework/menu.dart index 0ac749304..b423537f9 100644 --- a/lib/framework/menu.dart +++ b/lib/framework/menu.dart @@ -71,6 +71,7 @@ abstract class Menu { floatingAlignment: Utils.optionalString(item['floatingAlignment']) ?? 'center', floatingMargin: Utils.optionalInt(item['floatingMargin']), + switchScreen: Utils.getBool(item['switchScreen'], fallback: true), onTap: item['onTap'], onTapHaptic: Utils.optionalString(item['onTapHaptic']), isExternal: Utils.getBool(item['isExternal'], fallback: false), @@ -207,6 +208,7 @@ class MenuItem { this.iconLibrary, this.selected, this.floating = false, + this.switchScreen = true, this.floatingAlignment = 'center', this.floatingMargin, this.onTap, @@ -223,6 +225,7 @@ class MenuItem { final String? iconLibrary; final dynamic selected; final bool floating; + final bool switchScreen; final String floatingAlignment; final int? floatingMargin; final dynamic onTap; diff --git a/lib/framework/notification_manager.dart b/lib/framework/notification_manager.dart index ed76d97c9..cc015c444 100644 --- a/lib/framework/notification_manager.dart +++ b/lib/framework/notification_manager.dart @@ -95,18 +95,36 @@ class NotificationManager { }); _handleNotification(); }); + } + + void initGetInitialMessage() { + // This is called when the user taps on the notification and the app is opened from the terminated state + FirebaseMessaging.instance.getInitialMessage().then((message) { + if (message == null) return; - // TODO We need to handle the notification when the app was terminated + Ensemble.externalDataContext.addAll({ + 'title': message.notification?.title, + 'body': message.notification?.body, + 'data': message.data + }); + Ensemble() + .addCallbackAfterInitialization(method: () => _handleNotification()); + }).catchError((err) { + // ignore: avoid_print + print('Failed to get the remote notification'); + }); } - void _handleNotification() { + Future _handleNotification() async { Map? messageData = Ensemble.externalDataContext['data']; if (messageData?['screenId'] != null || messageData?['screenName'] != null) { - ScreenController().navigateToScreen(Utils.globalAppKey.currentContext!, - screenId: messageData!['screenId'], - screenName: messageData!['screenName'], - pageArgs: messageData); + ScreenController().navigateToScreen( + Utils.globalAppKey.currentContext!, + screenId: messageData!['screenId'], + screenName: messageData['screenName'], + pageArgs: messageData, + ); } else { log('No screenId nor screenName provided on the notification. Ignoring ...'); } diff --git a/lib/framework/studio_debugger.dart b/lib/framework/studio_debugger.dart index 50b24c033..2f022c928 100644 --- a/lib/framework/studio_debugger.dart +++ b/lib/framework/studio_debugger.dart @@ -1,5 +1,8 @@ import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/view/footer.dart'; import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/layout/box/box_layout.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:flutter/cupertino.dart'; class StudioDebugger { @@ -20,24 +23,45 @@ class StudioDebugger { /// This usually happen when the widget is scrollable and: /// 1. The parent is a Column which gives its child as much height as it needs /// 2. The parent is scrollable - void assertScrollableHasBoundedHeight( - BoxConstraints constraints, String widgetName) { + void assertScrollableHasBoundedHeight(BoxConstraints constraints, + String widgetName, BuildContext context, BoxController controller) { if (!constraints.hasBoundedHeight) { - throw LanguageError( - "$widgetName cannot be inside a parent with infinite height.", - recovery: - "1. If the parent is a Column, consider setting the $widgetName's expanded to true or give the $widgetName a height.\n2. If the parent is scrollable, make the parent not scrollable as this widget is already itself scrollable."); + if (!(FooterScope.of(context) != null && + ScrollableColumn.of(context) != null && + controller.expanded == false)) { + throw LanguageError( + "$widgetName cannot be inside a parent with infinite height.", + recovery: + "1. If the parent is a Column, consider setting the $widgetName's expanded to true or give the $widgetName a height.\n2. If the parent is scrollable, make the parent not scrollable as this widget is already itself scrollable."); + } } } /// wrap the widget inside a LayoutBuilder so we can assert unbounded height /// This should be used only when debugMode=true to minimize performance impact - Widget assertScrollableHasBoundedHeightWrapper( - Widget widget, String widgetName) => - LayoutBuilder(builder: (context, constraints) { - assertScrollableHasBoundedHeight(constraints, widgetName); - return widget; - }); + Widget assertScrollableHasBoundedHeightWrapper(Widget widget, + String widgetName, BuildContext context, BoxController controller) { + if (FooterScope.of(context) != null && + ScrollableColumn.of(context) != null && + controller.expanded == true) { + throw LanguageError( + "Within footer, $widgetName cannot have expanded true with scrollable Column as Parent", + recovery: + "1. Make the scrollable property of Column as false. \n2.If Listview does not respond to draggable scroller, this means that there is a $widgetName as parent who is already using the property \n If that is the case then, put scrollaBehaviour of footer inside the controller property of $widgetName"); + } + if (FooterScope.of(context) == null && + ScrollableColumn.of(context) != null && + controller.expanded == true) { + throw LanguageError("$widgetName cannot be inside a scrollable column.", + recovery: + "If the parent is scrollable, make the parent not scrollable as this widget is already itself scrollable."); + } + return LayoutBuilder(builder: (context, constraints) { + assertScrollableHasBoundedHeight( + constraints, widgetName, context, controller); + return widget; + }); + } /// wrap the widget inside a LayoutBuilder to assert unbounded height /// Use this only when debugMode=true diff --git a/lib/framework/view/bottom_nav_page_group.dart b/lib/framework/view/bottom_nav_page_group.dart index 91ff12059..5f82ff030 100644 --- a/lib/framework/view/bottom_nav_page_group.dart +++ b/lib/framework/view/bottom_nav_page_group.dart @@ -13,14 +13,17 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble/framework/widget/icon.dart' as ensemble; import 'package:flutter/material.dart'; -class FABBottomAppBarItem { - FABBottomAppBarItem({ +class BottomNavBarItem { + BottomNavBarItem({ required this.icon, required this.text, required this.isCustom, this.activeIcon, this.isFloating = false, this.floatingMargin, + this.switchScreen = true, + this.onTap, + this.onTapHaptic, }); Widget icon; @@ -29,6 +32,9 @@ class FABBottomAppBarItem { bool isFloating; bool isCustom; double? floatingMargin; + bool? switchScreen; + EnsembleAction? onTap; + String? onTapHaptic; } enum FloatingAlignment { @@ -240,7 +246,7 @@ class _BottomNavPageGroupState extends State } Widget? _buildBottomNavBar() { - List navItems = []; + List navItems = []; final unselectedColor = Utils.getColor(widget.menu.styles?['color']) ?? Theme.of(context).unselectedWidgetColor; @@ -272,11 +278,14 @@ class _BottomNavPageGroupState extends State : null); navItems.add( - FABBottomAppBarItem( + BottomNavBarItem( icon: icon, activeIcon: activeIcon, isCustom: isCustom, text: label, + switchScreen: item.switchScreen, + onTap: EnsembleAction.fromYaml(item.onTap), + onTapHaptic: item.onTapHaptic, ), ); } @@ -309,11 +318,29 @@ class _BottomNavPageGroupState extends State Utils.getShadowBlurStyle(widget.menu.styles?['shadowStyle']), notchedShape: const CircularNotchedRectangle(), onTabSelected: (index) { - if (widget.menu.reloadView == true) { - viewGroupNotifier.updatePage(index); + final isSwitchScreen = + Utils.getBool(navItems[index].switchScreen, fallback: true); + if (isSwitchScreen) { + if (widget.menu.reloadView == true) { + viewGroupNotifier.updatePage(index); + } else { + PageGroupWidget.getPageController(context)!.jumpToPage(index); + viewGroupNotifier.updatePage(index); + } + + _onTap(navItems[index]); } else { - PageGroupWidget.getPageController(context)!.jumpToPage(index); - viewGroupNotifier.updatePage(index); + // Execute only onTap action. Page switching is handled by the developer with onTap + _onTap(navItems[index]); + } + + // Executing haptic feedback action + if (navItems[index].onTapHaptic != null) { + ScreenController().executeAction( + context, + HapticAction( + type: navItems[index].onTapHaptic!, onComplete: null), + ); } }, items: navItems, @@ -325,6 +352,13 @@ class _BottomNavPageGroupState extends State ); } + void _onTap(BottomNavBarItem menuItem) { + if (menuItem.onTap != null) { + ScreenController().executeActionWithScope( + context, widget.scopeManager, menuItem.onTap!); + } + } + Widget? _buildCustomIcon(MenuItem item, {bool isActive = false}) { dynamic customWidgetModel = isActive ? item.customActiveWidget : item.customWidget; @@ -360,7 +394,7 @@ class EnsembleBottomAppBar extends StatefulWidget { }) { // assert(items.length == 2 || items.length == 4); } - final List items; + final List items; final int selectedIndex; final double? height; final dynamic margin; @@ -391,9 +425,11 @@ class EnsembleBottomAppBarState extends State { void _updateIndex(int index) { widget.onTabSelected(index); - setState(() { - _selectedIndex = index; - }); + if (widget.items[index].switchScreen == true) { + setState(() { + _selectedIndex = index; + }); + } } int? getFabIndex() { @@ -484,7 +520,7 @@ class EnsembleBottomAppBarState extends State { } Widget _buildTabItem({ - required FABBottomAppBarItem item, + required BottomNavBarItem item, required int index, required ValueChanged onPressed, }) { diff --git a/lib/framework/view/footer.dart b/lib/framework/view/footer.dart new file mode 100644 index 000000000..fe0807d4b --- /dev/null +++ b/lib/framework/view/footer.dart @@ -0,0 +1,271 @@ +import 'package:ensemble/framework/widget/has_children.dart'; +import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/layout/templated.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble/widget/helpers/widgets.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:ensemble/framework/action.dart' as action; +import 'package:yaml/yaml.dart'; +import '../../layout/list_view.dart' as ensemblelistview; +import '../../layout/grid_view.dart' as ensemblegridview; +import '../../layout/box/box_layout.dart' as ensemblecolumn; + +class Footer extends StatefulWidget + with + UpdatableContainer, + Invokable, + HasController { + Footer({super.key}); + + static const String type = "footer"; + + @override + State