import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/profile/profile_setup_page.dart'; import 'features/onboarding/hosted_connect_page.dart'; import 'features/onboarding/self_hosted_entry_sheet.dart'; import 'shared/auth/auth.dart'; import 'shared/theme/grid.dart'; import 'shared/theme/theme_extensions.dart'; import 'shared/widgets/mobile_tab_footer_backdrop.dart'; import 'shared/deeplink/deep_link.dart'; import 'shared/hosted/hosted_community_connection.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_environment.dart'; import 'features/onboarding/hosted_reconnect_banner.dart'; import 'features/onboarding/organization_sign_in_banner.dart'; import 'shared/hosted/hosted_flow_provider.dart'; import 'shared/hosted/organization_sign_in.dart'; /// Composition seam shared by signed-in and signed-out application states. class HostedAppGate extends HookConsumerWidget { const HostedAppGate({ super.key, required this.child, required this.authenticated, }); final Widget child; final bool authenticated; @override Widget build(BuildContext context, WidgetRef ref) { final flow = HostedEnvironment.enabled ? ref.watch(hostedFlowProvider) : null; useListenable(flow); useEffect(() { if (flow == null) return null; void onFlowChanged() { if (flow.stage == HostedFlowStage.complete) { ref.read(hostedInlineEntryProvider.notifier).setActive(false); } } flow.addListener(onFlowChanged); unawaited(flow.initialize()); return () => flow.removeListener(onFlowChanged); }, [flow]); final inlineEntry = ref.watch(hostedInlineEntryProvider); // Create the organization's browser owner before any callback is handled. ref.watch(organizationSignInProvider); final visible = flow != null && flow.stage != HostedFlowStage.idle && flow.stage != HostedFlowStage.complete; // Native browser authentication presents its own alert over the current // screen. Keep that screen in place while checking the session/opening it. final openingSignIn = flow != null && (flow.stage == HostedFlowStage.signIn || (flow.accountOnly && flow.stage == HostedFlowStage.working && flow.account.account == null)); final fullScreenSetup = visible && !openingSignIn; return DeepLinkDispatcher( dispatchMessageLinks: authenticated && !visible, onboardingHandler: (routeContext, link) async { final relay = link.relayUrl; if (relay != null) { final host = Uri.parse(relay).host.toLowerCase(); final requiresAuth = HostedEnvironment.requiresAuth(host); if (link is InviteDeepLink && !requiresAuth) return false; final communities = await ref .read(communityStorageProvider) .loadAll(); final existing = communities .where( (item) => relayIdentity(item.relayUrl) == relayIdentity(relay), ) .firstOrNull; if (existing != null && !(existing.starterSetupIncomplete && requiresAuth)) { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); if (context.mounted) { ref.read(communityEntryCompletionProvider.notifier).complete(); } return true; } if (!requiresAuth && link is AddCommunityDeepLink) { if (routeContext.mounted) { final joined = await showSelfHostedEntry( routeContext, relay: link.relayUrl, name: link.name, fullScreen: inlineEntry, ); if (joined && context.mounted) { ref.read(communityEntryCompletionProvider.notifier).complete(); } } return true; } if (requiresAuth && !HostedEnvironment.supportsRelay(Uri.parse(relay))) { if (routeContext.mounted) { await showDialog( context: routeContext, builder: (context) => AlertDialog( title: const Text('Community setup unavailable'), content: const Text( 'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ), ); } return true; } } if (flow == null) return false; await flow.start(link); return true; }, child: Stack( fit: StackFit.expand, children: [ ExcludeFocus( excluding: fullScreenSetup, child: ExcludeSemantics( excluding: fullScreenSetup, child: Offstage(offstage: fullScreenSetup, child: child), ), ), if (fullScreenSetup) ColoredBox(color: context.colors.surface), // These overlays only show over Home, so keep them above its // floating tab bar. Positioned.fill( child: MediaQuery( data: MediaQuery.of(context).copyWith( padding: MediaQuery.paddingOf( context, ).copyWith(bottom: mobileTabBarTop(context) + Grid.xxs), ), child: Stack( // Banners with their own Stack need the full layer to place // bottom-anchored content inside hit-testable bounds. fit: StackFit.expand, children: [ if (authenticated && !visible) const OrganizationSignInBanner(), if (flow != null && authenticated && !visible) HostedReconnectBanner(flow: flow), if (flow != null && flow.stage == HostedFlowStage.signIn && authenticated && !inlineEntry) Positioned( left: Grid.sm, right: Grid.sm, bottom: 0, child: SafeArea( child: Material( color: Colors.transparent, child: Wrap( alignment: WrapAlignment.center, children: [ TextButton( onPressed: flow.retry, child: const Text('Start login again'), ), TextButton( onPressed: flow.leave, child: Text( flow.reconnecting ? 'Cancel sign-in' : 'Leave setup', ), ), ], ), ), ), ), ], ), ), ), if (flow != null) ...[ if (fullScreenSetup) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), profileBuilder: (_) => ProfileSetupPage(flow: flow), ), ], ], ), ); } }