diff --git a/modules/ensemble/lib/action/bottom_sheet_actions.dart b/modules/ensemble/lib/action/bottom_sheet_actions.dart index 83381e0f6..0fd7dc2e5 100644 --- a/modules/ensemble/lib/action/bottom_sheet_actions.dart +++ b/modules/ensemble/lib/action/bottom_sheet_actions.dart @@ -21,6 +21,7 @@ class ShowBottomSheetAction extends EnsembleAction { required this.body, required this.payload, this.onDismiss, + this.useRoot = true, }); static const defaultTopBorderRadius = Radius.circular(16); @@ -30,6 +31,7 @@ class ShowBottomSheetAction extends EnsembleAction { final Map payload; final dynamic body; final EnsembleAction? onDismiss; + final bool useRoot; factory ShowBottomSheetAction.from({Invokable? initiator, Map? payload}) { dynamic body = payload?['body'] ?? payload?['widget']; @@ -42,6 +44,7 @@ class ShowBottomSheetAction extends EnsembleAction { inputs: Utils.getMap(payload['inputs']), body: body, onDismiss: EnsembleAction.from(payload['onDismiss']), + useRoot: Utils.getBool(payload['useRoot'], fallback: true), payload: payload); } @@ -103,6 +106,7 @@ class ShowBottomSheetAction extends EnsembleAction { if (body != null) { final body = getBodyWidget(scopeManager, context); showModalBottomSheet( + useRootNavigator: useRoot, context: context, // disable the default bottom sheet styling since we use our own backgroundColor: Colors.transparent, diff --git a/modules/ensemble/lib/action/dialog_actions.dart b/modules/ensemble/lib/action/dialog_actions.dart index 9ca15aea6..fbdfe3754 100644 --- a/modules/ensemble/lib/action/dialog_actions.dart +++ b/modules/ensemble/lib/action/dialog_actions.dart @@ -4,7 +4,9 @@ import 'package:ensemble/framework/data_context.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/bottom_nav_page_group.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/framework/view/page_group.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/ensemble_utils.dart'; import 'package:ensemble/util/utils.dart'; @@ -21,11 +23,13 @@ class ShowDialogAction extends EnsembleAction { required this.dismissible, this.options, this.onDialogDismiss, + this.useRoot = true, }); final dynamic body; final bool dismissible; final Map? options; + final bool useRoot; final EnsembleAction? onDialogDismiss; factory ShowDialogAction.from({Invokable? initiator, Map? payload}) { @@ -40,6 +44,7 @@ class ShowDialogAction extends EnsembleAction { Utils.maybeYamlMap(payload['widget']), options: Utils.getMap(payload['options']), dismissible: Utils.getBool(payload['dismissible'], fallback: true), + useRoot: Utils.getBool(payload['useRoot'], fallback: true), onDialogDismiss: payload['onDialogDismiss'] == null ? null : EnsembleAction.from(Utils.maybeYamlMap(payload['onDialogDismiss'])), @@ -57,8 +62,11 @@ class ShowDialogAction extends EnsembleAction { bool useDefaultStyle = dialogStyles['style'] != 'none'; BuildContext? dialogContext; + // Store the current tab observer provider for resubscription + final tabObserverProvider = TabRouteObserverProvider.of(context); + showGeneralDialog( - useRootNavigator: false, + useRootNavigator: useRoot, // use root navigator if specified // use inner-most MaterialApp (our App) as root so theming is ours context: context, barrierDismissible: dismissible, @@ -71,7 +79,8 @@ class ShowDialogAction extends EnsembleAction { dialogContext = context; scopeManager.openedDialogs.add(dialogContext!); - return Align( + // Wrap the dialog content with TabRouteObserverProvider to maintain tab context + Widget dialogWidget = Align( alignment: Alignment( Utils.getDouble(dialogStyles['horizontalOffset'], min: -1, max: 1, fallback: 0), @@ -113,6 +122,17 @@ class ShowDialogAction extends EnsembleAction { child: scopeManager .buildWidgetFromDefinition(body), )))))); + + // If we have a tab observer provider, wrap the dialog to maintain tab context + if (tabObserverProvider != null) { + return TabRouteObserverProvider( + routeObserver: tabObserverProvider.routeObserver, + tabIndex: tabObserverProvider.tabIndex, + child: dialogWidget, + ); + } + + return dialogWidget; }).then((payload) { // remove the dialog context since we are closing them scopeManager.openedDialogs.remove(dialogContext); @@ -123,9 +143,37 @@ class ShowDialogAction extends EnsembleAction { context, scopeManager, onDialogDismiss!, event: EnsembleEvent(initiator, data: payload)); } + + // Trigger resubscription to bottom nav navigator when dialog closes + _resubscribeToBottomNavNavigator(context, tabObserverProvider); }); return Future.value(null); } + + // Helper method to resubscribe to bottom nav navigator after dialog closes + void _resubscribeToBottomNavNavigator( + BuildContext context, TabRouteObserverProvider? tabObserverProvider) { + if (tabObserverProvider != null) { + // Schedule the resubscription for the next frame to ensure the dialog is fully closed + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + // Use the viewGroupNotifier's updatePage method to trigger a refresh + // This maintains the current page but forces a rebuild + int currentIndex = viewGroupNotifier.viewIndex; + viewGroupNotifier.updatePage(currentIndex, isReload: true); + } catch (e) {} + }); + } + } + + // Fallback method to trigger bottom nav rebuild + void _triggerBottomNavRebuild(BuildContext context) { + try { + // Use the viewGroupNotifier's public updatePage method + int currentIndex = viewGroupNotifier.viewIndex; + viewGroupNotifier.updatePage(currentIndex, isReload: true); + } catch (e) {} + } } /** @@ -164,4 +212,4 @@ class CloseAllDialogsAction extends EnsembleAction { scopeManager.openedDialogs.clear(); return Future.value(null); } -} \ No newline at end of file +} diff --git a/modules/ensemble/lib/framework/view/bottom_nav_page_group.dart b/modules/ensemble/lib/framework/view/bottom_nav_page_group.dart index 70e59c8cf..8776f8cab 100644 --- a/modules/ensemble/lib/framework/view/bottom_nav_page_group.dart +++ b/modules/ensemble/lib/framework/view/bottom_nav_page_group.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/ensemble.dart'; import 'package:ensemble/framework/action.dart'; @@ -7,14 +5,65 @@ import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/menu.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/view/bottom_nav_page_view.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/page_group.dart'; import 'package:ensemble/page_model.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/framework/widget/icon.dart' as ensemble; -import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:flutter/material.dart'; +// Import the global controller + +// Global notifier for bottom nav visibility +class BottomNavVisibilityNotifier extends ChangeNotifier { + static final BottomNavVisibilityNotifier _instance = + BottomNavVisibilityNotifier._internal(); + factory BottomNavVisibilityNotifier() => _instance; + BottomNavVisibilityNotifier._internal(); + + bool _isVisible = true; // Show by default for PageGroup + + bool get isVisible => _isVisible; + + void show() { + if (!_isVisible) { + _isVisible = true; + notifyListeners(); + } + } + + void hide() { + if (_isVisible) { + _isVisible = false; + notifyListeners(); + } + } + + void toggle() { + _isVisible = !_isVisible; + notifyListeners(); + } +} + +class TabRouteObserverProvider extends InheritedWidget { + const TabRouteObserverProvider({ + Key? key, + required this.routeObserver, + required this.tabIndex, + required Widget child, + }) : super(key: key, child: child); + + final RouteObserver routeObserver; + final int tabIndex; + + static TabRouteObserverProvider? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(TabRouteObserverProvider oldWidget) { + return routeObserver != oldWidget.routeObserver || tabIndex != oldWidget.tabIndex; + } +} class BottomNavBarItem { BottomNavBarItem({ @@ -27,6 +76,7 @@ class BottomNavBarItem { this.switchScreen = true, this.onTap, this.onTapHaptic, + this.page, }); Widget icon; @@ -38,6 +88,7 @@ class BottomNavBarItem { bool? switchScreen; EnsembleAction? onTap; String? onTapHaptic; + String? page; } enum FloatingAlignment { @@ -103,10 +154,10 @@ class BottomNavPageGroup extends StatefulWidget { final List children; @override - State createState() => _BottomNavPageGroupState(); + State createState() => BottomNavPageGroupState(); } -class _BottomNavPageGroupState extends State +class BottomNavPageGroupState extends State with RouteAware { late List menuItems; late PageController controller; @@ -114,9 +165,24 @@ class _BottomNavPageGroupState extends State int? floatingMargin; MenuItem? fabMenuItem; + // Use RouteObserver for proper tab-specific route observation + late final RouteObserver _mainRouteObserver; + late final List> _childRouteObservers; + + static List navpages = []; + @override void initState() { super.initState(); + // No unique page group ID needed + + // Initialize RouteObserver for proper route awareness + _mainRouteObserver = RouteObserver(); + _childRouteObservers = List.generate( + widget.children.length, + (index) => RouteObserver(), + ); + if (widget.menu.reloadView == false) { controller = PageController(initialPage: widget.selectedPage); } @@ -143,19 +209,25 @@ class _BottomNavPageGroupState extends State void didChangeDependencies() { super.didChangeDependencies(); - // TODO: this should be moved to PageGroup for the other ViewGroup types all behave the same way + // Subscribe to the global route observer for PageGroup-level navigation var route = ModalRoute.of(context); if (route is PageRoute) { + Ensemble().routeObserver.unsubscribe(this); Ensemble().routeObserver.subscribe(this, route); } } @override void dispose() { + // Unregister from global controller + if (widget.menu.reloadView == false) { controller.dispose(); } + + // Unsubscribe from global route observer Ensemble().routeObserver.unsubscribe(this); + super.dispose(); } @@ -224,45 +296,95 @@ class _BottomNavPageGroupState extends State return PageGroupWidgetWrapper( reloadView: widget.menu.reloadView, scopeManager: widget.scopeManager, - child: Scaffold( - resizeToAvoidBottomInset: true, - backgroundColor: notchColor, - bottomNavigationBar: _buildBottomNavBar(), - floatingActionButtonLocation: - floatingAlignment == FloatingAlignment.none - ? null - : floatingAlignment.location, - floatingActionButton: _buildFloatingButton(), - body: widget.menu.reloadView == true - ? ListenableBuilder( - listenable: viewGroupNotifier, - builder: (_, __) { - final screenPayload = - widget.screenPayload[viewGroupNotifier.viewIndex]; - final screen = ScreenController().getScreen( - key: ValueKey( - "${viewGroupNotifier.hashCode}:${viewGroupNotifier.viewIndex}"), - screenName: screenPayload.screenName, - pageArgs: - viewGroupNotifier.payload ?? screenPayload.arguments, - isExternal: screenPayload.isExternal, - ); - return screen; - }) - : Builder( - builder: (context) { - final controller = PageGroupWidget.getPageController(context); - - return BottomNavPageView( - controller: controller ?? PageController(), - children: widget.children, - ); - }, - ), + child: ListenableBuilder( + listenable: BottomNavVisibilityNotifier(), + builder: (context, _) { + final isBottomNavVisible = BottomNavVisibilityNotifier().isVisible; + + return Scaffold( + resizeToAvoidBottomInset: true, + backgroundColor: notchColor, + bottomNavigationBar: isBottomNavVisible ? _buildBottomNavBar() : null, + floatingActionButtonLocation: + floatingAlignment == FloatingAlignment.none + ? null + : floatingAlignment.location, + floatingActionButton: isBottomNavVisible ? _buildFloatingButton() : null, + body: widget.menu.reloadView == true + ? ListenableBuilder( + listenable: viewGroupNotifier, + builder: (_, __) { + final screenPayload = widget.screenPayload[viewGroupNotifier.viewIndex]; + final screen = ScreenController().getScreen( + key: ValueKey("${viewGroupNotifier.hashCode}:${viewGroupNotifier.viewIndex}"), + screenName: screenPayload.screenName, + pageArgs: viewGroupNotifier.payload ?? screenPayload.arguments, + isExternal: screenPayload.isExternal, + ); + + // Wrap with tab-specific observer + return TabRouteObserverProvider( + routeObserver: _mainRouteObserver, + tabIndex: viewGroupNotifier.viewIndex, + child: Navigator( + key: GlobalKey(), + observers: [_mainRouteObserver], + onGenerateRoute: (_) => MaterialPageRoute( + settings: RouteSettings(name: '/${screenPayload.screenName}'), + builder: (_) => screen, + ), + ), + ); + }, + ) + : Builder( + builder: (context) { + final controller = PageGroupWidget.getPageController(context); + + return BottomNavPageView( + controller: controller ?? PageController(), + children: widget.children + .asMap() + .entries + .map((entry) { + final screenName = widget.screenPayload[entry.key].screenName; + + // Wrap each tab with its own observer + return TabRouteObserverProvider( + routeObserver: _childRouteObservers[entry.key], + tabIndex: entry.key, + child: Navigator( + key: ValueKey("Navigator-${entry.key}"), + observers: [_childRouteObservers[entry.key]], + onGenerateRoute: (_) => MaterialPageRoute( + settings: RouteSettings(name: '/$screenName'), + builder: (_) => entry.value, + ), + ), + ); + }) + .toList(), + ); + }, + ), + ); + }, ), ); } + void _dismissAllRootDialogs(BuildContext context) { + try { + // Method 1: Pop all routes from root navigator until we get back to the app + final rootNavigator = Navigator.of(context, rootNavigator: true); + while (rootNavigator.canPop()) { + rootNavigator.pop(); + } + } catch (e) { + print('Error dismissing root dialogs: $e'); + } + } + Widget? _buildBottomNavBar() { List navItems = []; @@ -273,7 +395,6 @@ class _BottomNavPageGroupState extends State Utils.getColor(widget.menu.runtimeStyles?['selectedColor']) ?? Theme.of(context).primaryColor; - // final menu = widget.menu; for (int i = 0; i < menuItems.length; i++) { MenuItem item = menuItems[i]; final dynamic customIcon = _buildCustomIcon(item); @@ -295,7 +416,8 @@ class _BottomNavPageGroupState extends State fallbackColor: selectedColor, fallbackLibrary: item.iconLibrary) : null); - + + navpages.add(item.page ?? ''); navItems.add( BottomNavBarItem( icon: icon, @@ -305,6 +427,7 @@ class _BottomNavPageGroupState extends State switchScreen: Menu.evalSwitchScreen(widget.scopeManager.dataContext, item), onTap: EnsembleAction.from(item.onTap), onTapHaptic: item.onTapHaptic, + page: item.page ), ); } @@ -313,7 +436,6 @@ class _BottomNavPageGroupState extends State listenable: viewGroupNotifier, builder: (context, _) { final viewIndex = viewGroupNotifier.viewIndex; - return EnsembleBottomAppBar( key: UniqueKey(), selectedIndex: viewIndex, @@ -341,17 +463,21 @@ class _BottomNavPageGroupState extends State widget.menu.runtimeStyles?['shadowStyle']), notchedShape: const CircularNotchedRectangle(), onTabSelected: (index) { + _dismissAllRootDialogs(context); + final isSwitchScreen = Utils.getBool(navItems[index].switchScreen, fallback: true); if (isSwitchScreen) { if (widget.menu.reloadView == true) { + // Rebuild the active tab and its Navigator when reloadView=true viewGroupNotifier.updatePage(index); } else { - PageGroupWidget.getPageController(context)!.jumpToPage(index); - viewGroupNotifier.updatePage(index); + // Use the persistent PageController when reloadView=false + final controller = PageGroupWidget.getPageController(context); + controller?.jumpToPage(index); + // Keep the notifier in sync so the bottom bar highlights the right tab + viewGroupNotifier.updatePage(index, isReload: false); } - - _onTap(navItems[index]); } else { // Execute only onTap action. Page switching is handled by the developer with onTap _onTap(navItems[index]); @@ -388,6 +514,7 @@ class _BottomNavPageGroupState extends State if (customWidgetModel != null) { return widget.scopeManager.buildWidget(customWidgetModel!); } + return null; } } diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index d74f05c54..9ad906a3e 100644 --- a/modules/ensemble/lib/framework/view/page.dart +++ b/modules/ensemble/lib/framework/view/page.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'bottom_nav_page_group.dart'; // To access bottomNavVisibilityNotifier import 'dart:async'; import 'package:ensemble/ensemble.dart'; @@ -26,6 +27,124 @@ import 'package:ensemble/widget/helpers/unfocus.dart'; import 'package:ensemble/framework/bindings.dart'; import 'package:flutter/material.dart'; +class PageNavigatorWrapper extends StatefulWidget { + const PageNavigatorWrapper({ + Key? key, + required this.child, + required this.isTabPage, + this.tabIndex, + }) : super(key: key); + + final Widget child; + final bool isTabPage; + final int? tabIndex; + + @override + State createState() => _PageNavigatorWrapperState(); +} + +class _PageNavigatorWrapperState extends State + with RouteAware { + late final RouteObserver _routeObserver; + late final GlobalKey _navigatorKey; + + @override + void initState() { + super.initState(); + _routeObserver = RouteObserver(); + _navigatorKey = GlobalKey(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // Subscribe to route changes + var route = ModalRoute.of(context); + if (route is PageRoute) { + _routeObserver.unsubscribe(this); + _routeObserver.subscribe(this, route); + } + } + + @override + void dispose() { + _routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.isTabPage) { + // Wrap tab pages in their own Navigator + return Navigator( + key: _navigatorKey, + observers: [_routeObserver], + onGenerateRoute: (settings) { + return MaterialPageRoute( + settings: settings, + builder: (_) => widget.child, + ); + }, + ); + } + + // For non-tab pages, return child directly + return widget.child; + } + + // Navigator methods for external access + void pushPage(Widget page, {String? routeName}) { + if (widget.isTabPage && _navigatorKey.currentState != null) { + _navigatorKey.currentState!.push( + MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: (_) => page, + ), + ); + } + } + + void popPage() { + if (widget.isTabPage && _navigatorKey.currentState != null) { + _navigatorKey.currentState!.pop(); + } + } + + void popToRoot() { + if (widget.isTabPage && _navigatorKey.currentState != null) { + _navigatorKey.currentState!.popUntil((route) => route.isFirst); + } + } + + bool canPop() { + if (widget.isTabPage && _navigatorKey.currentState != null) { + return _navigatorKey.currentState!.canPop(); + } + return false; + } +} + +// Add this InheritedWidget to provide Navigator access +class PageNavigatorProvider extends InheritedWidget { + const PageNavigatorProvider({ + Key? key, + required this.navigatorWrapper, + required Widget child, + }) : super(key: key, child: child); + + final _PageNavigatorWrapperState navigatorWrapper; + + static PageNavigatorProvider? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(PageNavigatorProvider oldWidget) { + return navigatorWrapper != oldWidget.navigatorWrapper; + } +} + class SinglePageController extends WidgetController { TextStyleComposite? _textStyle; int? maxLines; @@ -212,14 +331,48 @@ class PageState extends State widget.rootScopeManager = _scopeManager; } + void _updateBottomNavVisibility() { + // Check if this page is a direct child of BottomNavPageGroup (tab page) + bool isTabPage = _isTabPage(); + + // Get showMenu setting + bool? showMenuSetting = widget._pageModel.runtimeStyles?['showMenu']; + + bool shouldShow = + isTabPage ? showMenuSetting != false : showMenuSetting == true; + shouldShow + ? BottomNavVisibilityNotifier().show() + : BottomNavVisibilityNotifier().hide(); + } + +// Helper method to check if this is a tab page + bool _isTabPage() { + try { + // Check if we have a TabRouteObserverProvider ancestor + final tabObserverProvider = TabRouteObserverProvider.of(context); + if (tabObserverProvider != null) { + // If we can find the provider, we're likely in a tab context + // Additional check: see if we're at the root of the tab's Navigator + final route = ModalRoute.of(context); + return route != null && route.isFirst; + } + return false; + } catch (e) { + return false; + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); // if our widget changes, we need to save the scopeManager to it. widget.rootScopeManager = _scopeManager; - // see if we are part of a ViewGroup or not + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateBottomNavVisibility(); + }); BottomNavScreen? bottomNavRootScreen = BottomNavScreen.getScreen(context); + print(bottomNavRootScreen?.bottomNavRoot.selectedScreen); if (bottomNavRootScreen != null) { bottomNavRootScreen.onReVisited(() { if (widget._pageModel.viewBehavior.onResume != null) { @@ -231,13 +384,7 @@ class PageState extends State }); } // standalone screen, listen when another screen is popped and we are back here - else { - var route = ModalRoute.of(context); - if (route is PageRoute) { - Ensemble().routeObserver.unsubscribe(this); - Ensemble().routeObserver.subscribe(this, route); - } - } + _subscribeToRouteObserver(); } /// the last time the screen went to the background @@ -295,6 +442,7 @@ class PageState extends State @override void didPushNext() { super.didPushNext(); + final observerType = _isUsingTabObserver ? 'TAB' : 'GLOBAL'; screenLastPaused = DateTime.now(); if (widget._pageModel.viewBehavior.onPause != null) { ScreenController().executeActionWithScope( @@ -303,10 +451,40 @@ class PageState extends State } } + RouteObserver? _currentObserver; + bool _isUsingTabObserver = false; + + void _subscribeToRouteObserver() { + var route = ModalRoute.of(context); + if (route is! PageRoute) return; + + // Try to get tab-specific observer first + final tabObserverProvider = TabRouteObserverProvider.of(context); + if (tabObserverProvider != null) { + _currentObserver = tabObserverProvider.routeObserver; + _isUsingTabObserver = true; + + _currentObserver!.unsubscribe(this); + _currentObserver!.subscribe(this, route); + return; + } + + // Fallback to global observer for standalone pages + _currentObserver = Ensemble().routeObserver; + _isUsingTabObserver = false; + + _currentObserver!.unsubscribe(this); + _currentObserver!.subscribe(this, route); + } + /// when a page is popped and we go back to this page @override void didPopNext() { super.didPopNext(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateBottomNavVisibility(); + }); + if (widget._pageModel.viewBehavior.onResume != null) { ScreenController().executeActionWithScope( context, _scopeManager, widget._pageModel.viewBehavior.onResume!, @@ -336,6 +514,10 @@ class PageState extends State ), importedCode: widget._pageModel.importedCode); widget.rootScopeManager = _scopeManager; + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateBottomNavVisibility(); + }); + // if we have a menu, figure out which child page to display initially if (widget._pageModel.menu != null && widget._pageModel.menu!.menuItems.length > 1) { @@ -470,7 +652,6 @@ class PageState extends State {required bool scrollableView, bool? showNavigationIcon}) { Widget? titleWidget; - if (headerModel.titleWidget != null) { titleWidget = _scopeManager.buildWidget(headerModel.titleWidget!); } @@ -524,7 +705,8 @@ class PageState extends State animationEnabled = Utils.getBool(animation!['enabled'], fallback: false); duration = Utils.getInt(animation!['duration'], fallback: 0); curve = Utils.getCurve(animation!['curve']); - animationType = Utils.getEnum(animation!['animationType'], AnimationType.values); + animationType = Utils.getEnum( + animation!['animationType'], AnimationType.values); } // applicable only to Sliver scrolling double? flexibleMaxHeight = @@ -553,9 +735,10 @@ class PageState extends State final titleBarHeight = isHeaderVisible ? baseTitleBarHeight : 0.0; if (scrollableView) { - return AnimatedAppBar( scrollController: externalScrollController!, + return AnimatedAppBar( + scrollController: externalScrollController!, automaticallyImplyLeading: - leadingWidget == null && showNavigationIcon != false, + leadingWidget == null && showNavigationIcon != false, leadingWidget: leadingWidget, titleWidget: titleWidget, centerTitle: centerTitle, @@ -799,6 +982,7 @@ class PageState extends State } Widget buildScrollablePageContent(bool hasDrawer) { + var route = ModalRoute.of(context); List slivers = []; externalScrollController = ScrollController(); // appBar @@ -1045,13 +1229,12 @@ class PageState extends State ScreenController().navigateToScreen(context, screenName: menuItem.page, isExternal: menuItem.isExternal); } + /// this method executes if this screen is part of ViewGroup /// and onViewGroupUpdate is defined in View void executeOnViewGroupUpdate() { if (widget._pageModel.viewBehavior.onViewGroupUpdate != null) { - ScreenController().executeActionWithScope( - context, - _scopeManager, + ScreenController().executeActionWithScope(context, _scopeManager, widget._pageModel.viewBehavior.onViewGroupUpdate!); } } @@ -1147,35 +1330,36 @@ class AnimatedAppBar extends StatefulWidget { final duration; AnimatedAppBar( {Key? key, - this.automaticallyImplyLeading, - this.leadingWidget, - this.titleWidget, - this.centerTitle, - this.backgroundColor, - this.surfaceTintColor, - this.foregroundColor, - this.elevation, - this.shadowColor, - this.titleBarHeight, - this.backgroundWidget, - this.animated, - this.floating, - this.pinned, - this.collapsedBarHeight, - this.expandedBarHeight, - required this.scrollController, - this.curve, - this.animationType, - this.duration}) + this.automaticallyImplyLeading, + this.leadingWidget, + this.titleWidget, + this.centerTitle, + this.backgroundColor, + this.surfaceTintColor, + this.foregroundColor, + this.elevation, + this.shadowColor, + this.titleBarHeight, + this.backgroundWidget, + this.animated, + this.floating, + this.pinned, + this.collapsedBarHeight, + this.expandedBarHeight, + required this.scrollController, + this.curve, + this.animationType, + this.duration}) : super(key: key); @override _AnimatedAppBarState createState() => _AnimatedAppBarState(); } -class _AnimatedAppBarState extends State with WidgetsBindingObserver{ +class _AnimatedAppBarState extends State + with WidgetsBindingObserver { bool isCollapsed = false; - + @override void initState() { super.initState(); @@ -1183,12 +1367,12 @@ class _AnimatedAppBarState extends State with WidgetsBindingObse } void _updateCollapseState() { - if (!widget.scrollController.hasClients) return; double expandedHeight = (widget.expandedBarHeight ?? 0.0).toDouble(); double collapsedHeight = (widget.collapsedBarHeight ?? 0.0).toDouble(); - double threshold = (expandedHeight - collapsedHeight).clamp(10.0, double.infinity); + double threshold = + (expandedHeight - collapsedHeight).clamp(10.0, double.infinity); bool newState = widget.scrollController.offset > threshold; if (newState != isCollapsed) { @@ -1239,21 +1423,21 @@ class _AnimatedAppBarState extends State with WidgetsBindingObse centerTitle: widget.centerTitle, title: widget.animated ? switch (widget.animationType) { - AnimationType.fade => AnimatedOpacity( - opacity: isCollapsed ? 1.0 : 0.0, - duration: Duration(milliseconds: widget.duration ?? 300), - curve: widget.curve ?? Curves.easeIn, - child: widget.titleWidget, - ), - AnimationType.drop => AnimatedSlide( - offset: isCollapsed ? Offset(0, 0) : Offset(0, -2), - duration: Duration(milliseconds: widget.duration ?? 300), - curve: widget.curve ?? Curves.easeIn, - child: widget.titleWidget, - ), - _ => widget.titleWidget, - } - : widget.titleWidget, + AnimationType.fade => AnimatedOpacity( + opacity: isCollapsed ? 1.0 : 0.0, + duration: Duration(milliseconds: widget.duration ?? 300), + curve: widget.curve ?? Curves.easeIn, + child: widget.titleWidget, + ), + AnimationType.drop => AnimatedSlide( + offset: isCollapsed ? Offset(0, 0) : Offset(0, -2), + duration: Duration(milliseconds: widget.duration ?? 300), + curve: widget.curve ?? Curves.easeIn, + child: widget.titleWidget, + ), + _ => widget.titleWidget, + } + : widget.titleWidget, elevation: widget.elevation, backgroundColor: widget.backgroundColor, flexibleSpace: wrapsInFlexible(widget.backgroundWidget), @@ -1272,7 +1456,7 @@ enum ScrollMode { floating, } -enum AnimationType{ +enum AnimationType { drop, fade, } diff --git a/modules/ensemble/lib/screen_controller.dart b/modules/ensemble/lib/screen_controller.dart index 9b956f4c8..128d6bcfd 100644 --- a/modules/ensemble/lib/screen_controller.dart +++ b/modules/ensemble/lib/screen_controller.dart @@ -1,10 +1,8 @@ // ignore_for_file: use_build_context_synchronously, avoid_print import 'dart:async'; -import 'dart:convert'; import 'package:ensemble/action/navigation_action.dart'; -import 'package:ensemble/action/phone_contact_action.dart'; import 'package:ensemble/action/upload_files_action.dart'; import 'package:ensemble/ensemble.dart'; import 'package:ensemble/ensemble_app.dart'; @@ -12,15 +10,11 @@ import 'package:ensemble/framework/action.dart'; import 'package:ensemble/framework/apiproviders/api_provider.dart'; import 'package:ensemble/framework/bindings.dart'; import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/devmode.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/permissions_manager.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/stub/camera_manager.dart'; -import 'package:ensemble/framework/stub/contacts_manager.dart'; -import 'package:ensemble/framework/stub/plaid_link_manager.dart'; import 'package:ensemble/framework/theme/theme_loader.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/page.dart' as ensemble; @@ -34,7 +28,6 @@ import 'package:ensemble/util/ensemble_utils.dart'; import 'package:ensemble/util/notification_utils.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/stub_widgets.dart'; -import 'package:ensemble_ts_interpreter/invokables/context.dart'; import 'package:ensemble_ts_interpreter/parser/newjs_interpreter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -540,6 +533,24 @@ class ScreenController { }) async { PageType pageType = asModal == true ? PageType.modal : PageType.regular; + // If no explicit route option was provided and we're navigating + // to a ViewGroup, default to replacing the current screen to avoid + // stacking multiple ViewGroups (which can cause duplicate nav bars). + if (routeOption == null && screenName != null) { + try { + final def = await Ensemble() + .getConfig()! + .definitionProvider + .getDefinition(screenName: screenName); + final model = def.getModel(pageArgs); + if (model is PageGroupModel) { + routeOption = RouteOption.replaceCurrentScreen; + } + } catch (_) { + // Fall back silently if detection fails. + } + } + Widget screenWidget = getScreen( screenId: screenId, screenName: screenName, @@ -570,25 +581,31 @@ class ScreenController { alignment: alignment, duration: duration, ); + // When target is a ViewGroup, use the root Navigator to avoid stacking a ViewGroup + // inside a nested tab Navigator. Otherwise, use the default navigator. + final _targetIsViewGroup = + routeOption == RouteOption.replaceCurrentScreen && screenName != null; + final navigator = Navigator.of(context, rootNavigator: _targetIsViewGroup); + // push the new route and remove all existing screens. This is suitable for logging out. if (routeOption == RouteOption.clearAllScreens) { if (asExternal) { externalAppNavigateKey?.currentState ?.pushAndRemoveUntil(route, (route) => false); } else { - await Navigator.pushAndRemoveUntil(context, route, (route) => false); + await navigator.pushAndRemoveUntil(route, (route) => false); } } else if (routeOption == RouteOption.replaceCurrentScreen) { if (asExternal) { externalAppNavigateKey?.currentState?.pushReplacement(route); } else { - await Navigator.pushReplacement(context, route); + await navigator.pushReplacement(route); } } else { if (asExternal) { externalAppNavigateKey?.currentState?.push(route); } else { - await Navigator.push(context, route); + await navigator.push(route); } } return route;