diff --git a/app/lib/screens/app_onboarding/iframe_helper.dart b/app/lib/screens/app_onboarding/iframe_helper.dart index efe486567..4db89c175 100644 --- a/app/lib/screens/app_onboarding/iframe_helper.dart +++ b/app/lib/screens/app_onboarding/iframe_helper.dart @@ -1,25 +1,70 @@ +import 'dart:async'; import 'dart:convert'; import 'package:studyu_app/models/app_state.dart'; -import 'package:studyu_core/core.dart'; +import 'package:studyu_core/core.dart' show Study; import 'package:studyu_core/env.dart' as env; import "package:universal_html/html.dart" as html; +typedef PreviewNavigationHandler = Future Function(String? route); + class IFrameHelper { + static StreamSubscription? _messageSubscription; + + String _designerOrigin() { + final referrer = html.document.referrer; + if (referrer.isNotEmpty) { + final uri = Uri.tryParse(referrer); + if (uri != null && uri.hasScheme && uri.host.isNotEmpty) { + return uri.origin; + } + } + return env.designerUrl!; + } + + void postPreviewStatus({required String status, String? message}) { + html.window.parent!.postMessage( + jsonEncode({ + 'type': 'previewStatus', + 'status': status, + if (message != null) 'message': message, + }), + _designerOrigin(), + ); + } + void postRouteFinished() { // Go back to the selected origin route - html.window.parent!.postMessage('routeFinished', env.designerUrl!); + html.window.parent!.postMessage('routeFinished', _designerOrigin()); } - void listen(AppState state) { - html.window.onMessage.listen((event) { - final message = event.data as String; - final messageContent = jsonDecode(message) as Map; - // if (messageContent['intervention'] != null) { - // print(messageContent['intervention']); - // print("AppListen: " + messageContent.toString()); + void listen(AppState state, {PreviewNavigationHandler? onNavigate}) { + _messageSubscription?.cancel(); + _messageSubscription = html.window.onMessage.listen((event) async { + final data = event.data; + if (data is! String) return; + + final Object? decodedMessage; + try { + decodedMessage = jsonDecode(data); + } catch (_) { + return; + } + if (decodedMessage is! Map) return; + + final messageContent = decodedMessage; + if (messageContent['type'] == 'previewNavigate') { + await onNavigate?.call(messageContent['route'] as String?); + return; + } + + if (messageContent.containsKey('type') || + messageContent['id'] is! String || + messageContent['user_id'] is! String) { + return; + } + state.updateStudy(Study.fromJson(messageContent)); - // } }); } } diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index 215c9bc75..f62312d98 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/main.dart' show navigatorKey; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/routes.dart'; import 'package:studyu_app/screens/app_onboarding/iframe_helper.dart'; @@ -24,6 +25,10 @@ class LoadingScreen extends StatefulWidget { } class _LoadingScreenState extends State { + final IFrameHelper _iFrameHelper = IFrameHelper(); + bool _previewNavigationInProgress = false; + String? _pendingPreviewRoute; + @override void initState() { super.initState(); @@ -32,13 +37,27 @@ class _LoadingScreenState extends State { Future initStudy() async { final state = context.read(); - await _initPreview(state); + try { + await _initPreview(state); + } catch (error, stackTrace) { + StudyULogger.error( + 'Preview failed to initialize.', + error: error, + stackTrace: stackTrace, + ); + _iFrameHelper.postPreviewStatus( + status: 'error', + message: + 'The preview could not be opened for this study yet. Please try resetting the preview.', + ); + rethrow; + } final selectedSubjectId = await getActiveSubjectId(); if (!mounted) return; if (selectedSubjectId == null) { - await noSubjectFound(); + await noSubjectFound(state); return; } StudyULogger.info("Retrieving subject with ID: $selectedSubjectId"); @@ -61,14 +80,18 @@ class _LoadingScreenState extends State { } } - Future noSubjectFound() async { + Future noSubjectFound(AppState state) async { await cancelNotifications(context); final bool onBoarded = await SecureStorage.readBool('onboarded') ?? false; - // If no subject found and user has not done any onboarding, redirect to onboarding - final route = onBoarded ? Routes.terms : Routes.onboarding; + // Designer previews should skip the generic app introduction and go straight + // to the participant study flow. + final route = state.isPreview || onBoarded + ? Routes.terms + : Routes.onboarding; if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); Navigator.pushReplacementNamed(context, route); } @@ -125,19 +148,28 @@ class _LoadingScreenState extends State { ); final lang = AppLanguage(AppLocalizations.supportedLocales); final preview = study_preview.Preview(widget.queryParameters, lang); - final iFrameHelper = IFrameHelper(); state.isPreview = true; + _iFrameHelper.postPreviewStatus(status: 'loading'); await preview.init(); // Authorize - if (!await preview.handleAuthorization()) { + final isAuthorized = await preview.handleAuthorization(); + if (!isAuthorized) { + _iFrameHelper.postPreviewStatus( + status: 'error', + message: + 'The preview could not be opened right now. Please try resetting the preview.', + ); return; } state.selectedStudy = preview.study; await preview.runCommands(); - iFrameHelper.listen(state); + _iFrameHelper.listen( + state, + onNavigate: (route) => _navigatePreviewRoute(state, route), + ); if (preview.hasRoute()) { // print('[PreviewApp]: Found preview route:: ${preview.selectedRoute}'); @@ -145,21 +177,23 @@ class _LoadingScreenState extends State { // ELIGIBILITY CHECK if (preview.selectedRoute == '/eligibilityCheck') { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); // if we remove the await, we can push multiple times. warning: do not run in while(true) await Navigator.push( context, EligibilityScreen.routeFor(study: preview.study), ); // either do the same navigator push again or --> send a message back to designer and let it reload the whole page <-- - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } // INTERVENTION SELECTION if (preview.selectedRoute == Routes.interventionSelection) { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.pushNamed(context, Routes.interventionSelection); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } @@ -171,24 +205,27 @@ class _LoadingScreenState extends State { // CONSENT if (preview.selectedRoute == Routes.consent) { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.pushNamed(context, Routes.consent); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } // JOURNEY if (preview.selectedRoute == Routes.journey) { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.pushNamed(context, Routes.journey); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } // DASHBOARD if (preview.selectedRoute == Routes.dashboard) { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.pushReplacementNamed(context, Routes.dashboard); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } @@ -200,8 +237,9 @@ class _LoadingScreenState extends State { state.selectedStudy!.schedule.includeBaseline = false; state.activeSubject!.study.schedule.includeBaseline = false; if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.pushReplacementNamed(context, Routes.dashboard); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } @@ -213,6 +251,7 @@ class _LoadingScreenState extends State { ), ]; if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); await Navigator.push( context, TaskScreen.routeFor( @@ -222,7 +261,7 @@ class _LoadingScreenState extends State { ), ), ); - iFrameHelper.postRouteFinished(); + _iFrameHelper.postRouteFinished(); return; } } else { @@ -231,21 +270,117 @@ class _LoadingScreenState extends State { if (subject != null) { state.activeSubject = subject; if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); Navigator.pushReplacementNamed(context, Routes.dashboard); return; } else { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); Navigator.pushReplacementNamed(context, Routes.studyOverview); return; } } else { if (!mounted) return; + _iFrameHelper.postPreviewStatus(status: 'loaded'); Navigator.pushReplacementNamed(context, Routes.welcome); return; } } } + Future _navigatePreviewRoute(AppState state, String? route) async { + if (_previewNavigationInProgress) { + _pendingPreviewRoute = route; + return; + } + _previewNavigationInProgress = true; + + try { + final navigator = navigatorKey.currentState; + if (navigator == null) return; + + Future ensureSubject() async { + if (state.activeSubject != null) return true; + if (state.selectedStudy == null) return false; + + final preview = study_preview.Preview( + { + ...?widget.queryParameters, + if (route != null) 'route': route, + }, + AppLanguage(AppLocalizations.supportedLocales), + ); + await preview.init(); + preview.study = state.selectedStudy; + state.activeSubject = await preview.getStudySubject( + state, + createSubject: true, + ); + return state.activeSubject != null; + } + + Future waitForNavigator() async { + await WidgetsBinding.instance.endOfFrame; + await Future.delayed(const Duration(milliseconds: 120)); + } + + Future replaceNamed(String routeName) async { + await waitForNavigator(); + navigatorKey.currentState?.pushReplacementNamed(routeName); + } + + Future replaceWithEligibility() async { + if (state.selectedStudy == null) return; + await waitForNavigator(); + navigatorKey.currentState?.pushReplacement( + EligibilityScreen.routeFor(study: state.selectedStudy), + ); + } + + if (route == null || route.isEmpty) { + await replaceNamed(Routes.studyOverview); + return; + } + + if (route == 'eligibilityCheck') { + if (state.selectedStudy == null) return; + await replaceWithEligibility(); + return; + } + + if (route == Routes.interventionSelection || + route == 'interventionSelection') { + await replaceNamed(Routes.interventionSelection); + return; + } + + if (!await ensureSubject()) { + _iFrameHelper.postPreviewStatus( + status: 'error', + message: 'The preview route could not be opened right now.', + ); + return; + } + + if (route == 'consent') { + await replaceNamed(Routes.consent); + } else if (route == 'journey') { + await replaceNamed(Routes.journey); + } else if (route == 'dashboard') { + await replaceNamed(Routes.dashboard); + } + } finally { + _previewNavigationInProgress = false; + final pendingRoute = _pendingPreviewRoute; + _pendingPreviewRoute = null; + if (pendingRoute != null && pendingRoute != route) { + await _navigatePreviewRoute(state, pendingRoute); + } else { + _iFrameHelper.postPreviewStatus(status: 'loaded'); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/app/lib/screens/app_onboarding/preview.dart b/app/lib/screens/app_onboarding/preview.dart index d80584e37..f4d95b8d2 100644 --- a/app/lib/screens/app_onboarding/preview.dart +++ b/app/lib/screens/app_onboarding/preview.dart @@ -31,7 +31,13 @@ class Preview { Future init() async { previewSubjectIdKey(); - selectedStudyObjectId = await getActiveSubjectId(); + try { + selectedStudyObjectId = await getActiveSubjectId().timeout( + const Duration(seconds: 5), + ); + } catch (_) { + selectedStudyObjectId = null; + } if (containsQuery('languageCode')) { final locale = Locale(queryParameters!['languageCode']!); @@ -183,6 +189,8 @@ class Preview { print( '[PreviewApp]: Failed fetching subject. Maybe subject was reset? Error: $e', ); + await deleteActiveStudyReference(); + selectedStudyObjectId = null; // todo try sign in again if token expired see loading screen } } @@ -228,17 +236,32 @@ class Preview { List getInterventionIds() { final interventionList = study!.interventions.map((i) => i.id).toList(); + if (interventionList.isEmpty) { + return const []; + } + List newInterventionList = []; // If we have a specific intervention we want to show, select that and another one if (selectedRoute == '/intervention' && extra != null) { - final String intId = interventionList.firstWhere((id) => id == extra); - newInterventionList - ..add(intId) - ..add(interventionList.firstWhere((id) => id != intId)); - assert(newInterventionList.length == 2); + final String? selectedInterventionId = interventionList + .cast() + .firstWhere((id) => id == extra, orElse: () => null); + + if (selectedInterventionId != null) { + newInterventionList.add(selectedInterventionId); + } + + final String? alternativeInterventionId = interventionList + .cast() + .firstWhere( + (id) => id != null && id != selectedInterventionId, + orElse: () => null, + ); + if (alternativeInterventionId != null) { + newInterventionList.add(alternativeInterventionId); + } } else { - // just take the first two - newInterventionList = interventionList.sublist(0, 2); + newInterventionList = interventionList.take(2).toList(); } return newInterventionList; } diff --git a/app/lib/widgets/bottom_onboarding_navigation.dart b/app/lib/widgets/bottom_onboarding_navigation.dart index 6ea37f00e..a00f2abcb 100644 --- a/app/lib/widgets/bottom_onboarding_navigation.dart +++ b/app/lib/widgets/bottom_onboarding_navigation.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/routes.dart'; class BottomOnboardingNavigation extends StatelessWidget { final VoidCallback? onBack; @@ -29,6 +32,31 @@ class BottomOnboardingNavigation extends StatelessWidget { @override Widget build(BuildContext context) { + void handleBack() { + if (onBack != null) { + onBack!.call(); + return; + } + + final appState = context.read(); + final currentRoute = ModalRoute.of(context)?.settings.name; + if (appState.isPreview) { + final previousRoute = switch (currentRoute) { + Routes.dashboard => Routes.journey, + Routes.journey => Routes.consent, + Routes.consent => Routes.studyOverview, + Routes.interventionSelection => Routes.studyOverview, + _ => null, + }; + if (previousRoute != null) { + Navigator.pushReplacementNamed(context, previousRoute); + return; + } + } + + Navigator.pop(context); + } + return BottomAppBar( child: Padding( padding: const EdgeInsets.all(8), @@ -40,9 +68,7 @@ class BottomOnboardingNavigation extends StatelessWidget { maintainAnimation: true, maintainState: true, child: TextButton( - onPressed: backEnabled - ? (onBack ?? () => Navigator.pop(context)) - : null, + onPressed: backEnabled ? handleBack : null, child: Row( children: [ backIcon ?? const Icon(Icons.navigate_before), diff --git a/designer_v2/lib/features/study/study_test_frame.dart b/designer_v2/lib/features/study/study_test_frame.dart index 54dd18feb..51b71d5a8 100644 --- a/designer_v2/lib/features/study/study_test_frame.dart +++ b/designer_v2/lib/features/study/study_test_frame.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:js_interop'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -14,6 +15,7 @@ import 'package:studyu_designer_v2/features/study/study_test_controls.dart'; import 'package:studyu_designer_v2/features/study/study_test_frame_controllers.dart'; import 'package:studyu_designer_v2/features/study/study_test_frame_views.dart'; import 'package:studyu_designer_v2/routing/router_config.dart'; +import 'package:web/web.dart' as web; class PreviewFrame extends ConsumerStatefulWidget { const PreviewFrame(this.studyId, {this.routeArgs, this.route, super.key}) @@ -33,10 +35,17 @@ class PreviewFrame extends ConsumerStatefulWidget { } class _PreviewFrameState extends ConsumerState { + static const Duration _healthCheckTimeout = Duration(seconds: 5); PlatformController? frameController; - bool _frameError = false; + PlatformController? _activeFrameController; ProviderSubscription? _studyReadySubscription; StreamSubscription? _formChangesSubscription; + String? _lastPreviewUrl; + PreviewOverlayStage _overlayStage = PreviewOverlayStage.healthChecking; + String? _overlayMessage; + int _appHealthRequestId = 0; + bool _iframeConnected = false; + bool _frameActivated = false; @override void initState() { @@ -75,59 +84,196 @@ class _PreviewFrameState extends ConsumerState { _formChangesSubscription = formViewModelCurrent.form.valueChanges.listen(( event, ) { - if (frameController != null && !_frameError) { + if (frameController != null) { final formJson = jsonEncode( formViewModelCurrent.buildFormData().toJson(), ); try { frameController!.send(formJson); - } catch (e) { - setState(() { - _frameError = true; - }); - } + } catch (e) {} } }); } void _updatePreviewRoute() { + if (_activeFrameController == null) return; if (widget.route != null) { - frameController!.generateUrl(route: widget.route); + _activeFrameController!.generateUrl(route: widget.route); } else { String route = 'default'; if (widget.routeArgs is InterventionFormRouteArgs) { route = 'intervention'; - frameController!.generateUrl( + _activeFrameController!.generateUrl( route: route, extra: (widget.routeArgs! as InterventionFormRouteArgs).interventionId, ); } else if (widget.routeArgs is MeasurementFormRouteArgs) { route = 'observation'; - frameController!.generateUrl( + _activeFrameController!.generateUrl( route: route, extra: (widget.routeArgs! as MeasurementFormRouteArgs).measurementId, ); } else { - frameController!.generateUrl(); + _activeFrameController!.generateUrl(); } } } + Uri? _configuredAppUri(String appUrl) => Uri.tryParse(appUrl); + + bool _isLocalPreviewUrl(String appUrl) { + final uri = _configuredAppUri(appUrl); + final host = uri?.host.toLowerCase(); + return host == 'localhost' || host == '127.0.0.1'; + } + + String _configuredPreviewOrigin(String appUrl) { + final uri = _configuredAppUri(appUrl); + if (uri == null) return appUrl; + return uri.origin; + } + + void _markLoadStarted() { + if (!mounted) return; + setState(() { + _iframeConnected = false; + _frameActivated = false; + _overlayStage = PreviewOverlayStage.healthChecking; + _overlayMessage = null; + }); + } + + void _markIframeConnected() { + if (!mounted) return; + setState(() { + _iframeConnected = true; + _overlayStage = PreviewOverlayStage.appLoading; + _overlayMessage = null; + }); + } + + void _markAppLoading() { + if (!mounted || !_iframeConnected) return; + setState(() { + _overlayStage = PreviewOverlayStage.appLoading; + _overlayMessage = null; + }); + } + + void _markAppReady() { + if (!mounted) return; + setState(() { + _overlayStage = PreviewOverlayStage.none; + _overlayMessage = null; + }); + } + + void _markAppError(String message) { + if (!mounted) return; + setState(() { + _overlayStage = PreviewOverlayStage.error; + _overlayMessage = message; + }); + } + + Future _runHealthCheckAndLoad(String previewUrl) async { + final uri = Uri.tryParse(previewUrl); + final origin = uri?.origin; + if (origin == null || origin.isEmpty) { + if (!mounted) return; + setState(() { + _overlayStage = PreviewOverlayStage.error; + _overlayMessage = 'The app preview URL is not configured correctly.'; + }); + return; + } + + final requestId = ++_appHealthRequestId; + if (mounted) { + setState(() { + _overlayStage = PreviewOverlayStage.healthChecking; + _overlayMessage = 'Checking whether the app at $origin is reachable.'; + _iframeConnected = false; + _frameActivated = false; + }); + } + + try { + await web.window.fetch( + origin.toJS, + web.RequestInit(method: 'GET', mode: 'no-cors'), + ).toDart.timeout(_healthCheckTimeout); + + if (!mounted || requestId != _appHealthRequestId) return; + setState(() { + _overlayStage = PreviewOverlayStage.connecting; + _overlayMessage = 'Connecting to the app preview at $origin.'; + }); + + _activeFrameController!.activate(); + _activeFrameController!.listen(); + _activeFrameController!.refresh(cmd: "reset"); + if (!mounted || requestId != _appHealthRequestId) return; + setState(() { + _frameActivated = true; + }); + } catch (_) { + if (!mounted || requestId != _appHealthRequestId) return; + setState(() { + _overlayStage = PreviewOverlayStage.error; + _overlayMessage = _isLocalPreviewUrl(previewUrl) + ? 'The local StudyU app at $origin is not reachable. Start the app in local mode, then click Reset.' + : 'The StudyU mobile app is temporarily unavailable or under maintenance. Please try again in a little while.'; + }); + } + } + + void _ensureFrameController() { + final nextController = frameController; + if (nextController == null) return; + if (identical(_activeFrameController, nextController)) return; + + _activeFrameController = nextController; + _activeFrameController! + ..onLoadStarted = _markLoadStarted + ..onConnected = _markIframeConnected + ..onLoading = _markAppLoading + ..onReady = _markAppReady + ..onError = _markAppError; + + _lastPreviewUrl = null; + _updatePreviewRoute(); + _lastPreviewUrl = _activeFrameController!.previewSrc; + unawaited(_runHealthCheckAndLoad(_activeFrameController!.previewSrc)); + } + + @override + void didUpdateWidget(covariant PreviewFrame oldWidget) { + super.didUpdateWidget(oldWidget); + if (_activeFrameController == null) return; + + _updatePreviewRoute(); + final nextPreviewUrl = _activeFrameController!.previewSrc; + if (_lastPreviewUrl != nextPreviewUrl) { + _lastPreviewUrl = nextPreviewUrl; + unawaited(_runHealthCheckAndLoad(nextPreviewUrl)); + } + } + @override Widget build(BuildContext context) { final state = ref.watch(studyTestControllerProvider(widget.studyId)); final formViewModel = ref.watch(studyTestValidatorProvider(widget.studyId)); + final configuredPreviewOrigin = _configuredPreviewOrigin(state.appUrl); + final isLocalDevelopment = _isLocalPreviewUrl(state.appUrl); // Rebuild iframe component and url frameController = ref.watch( studyTestPlatformControllerProvider(widget.studyId), ); - _updatePreviewRoute(); - frameController!.activate(); - frameController!.listen(); - frameController!.refresh(cmd: "reset"); + _ensureFrameController(); return LayoutBuilder( builder: (context, constraints) { @@ -145,16 +291,50 @@ class _PreviewFrameState extends ConsumerState { formGroup: formViewModel.form, child: ReactiveFormConsumer( builder: (context, form, child) { - if (formViewModel.form.hasErrors || _frameError) { + if (formViewModel.form.hasErrors) { return const DisabledFrame(); } return Column( children: [ - frameController!.frameWidget, + Stack( + children: [ + if (_frameActivated) + _activeFrameController!.frameWidget + else + PhoneContainer( + innerContent: const SizedBox.expand(), + ), + if (_overlayStage == PreviewOverlayStage.error) + Positioned.fill( + child: ErrorFrame( + title: isLocalDevelopment + ? 'Local app preview unavailable' + : 'App under maintenance', + message: + _overlayMessage ?? + (isLocalDevelopment + ? 'The local StudyU app could not be reached.' + : 'The StudyU mobile app is temporarily unavailable or under maintenance. Please try again in a little while.'), + ), + ), + if (_overlayStage != PreviewOverlayStage.none && + _overlayStage != PreviewOverlayStage.error) + Positioned.fill( + child: LoadingFrame( + configuredUrl: configuredPreviewOrigin, + isLocalDevelopment: isLocalDevelopment, + stage: _overlayStage, + message: _overlayMessage, + ), + ), + ], + ), const SizedBox(height: 8.0), FrameControlsWidget( onRefresh: () { - frameController!.refresh(cmd: "reset"); + unawaited( + _runHealthCheckAndLoad(frameController!.previewSrc), + ); }, onOpenNewTab: () => frameController!.openNewPage(), enabled: state.canTest, diff --git a/designer_v2/lib/features/study/study_test_frame_controllers.dart b/designer_v2/lib/features/study/study_test_frame_controllers.dart index 561ee3aab..c0b3bddd2 100644 --- a/designer_v2/lib/features/study/study_test_frame_controllers.dart +++ b/designer_v2/lib/features/study/study_test_frame_controllers.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:js_interop' as js; import 'dart:js_interop_unsafe'; import 'dart:ui_web' as ui; @@ -24,9 +25,15 @@ class RouteInformation { abstract class PlatformController { final String studyId; final String baseSrc; + final ValueNotifier navigationEnabled = ValueNotifier(false); late String previewSrc; late RouteInformation routeInformation; late Widget frameWidget; + VoidCallback? onLoadStarted; + VoidCallback? onConnected; + VoidCallback? onLoading; + VoidCallback? onReady; + ValueChanged? onError; PlatformController(this.baseSrc, this.studyId); @@ -42,6 +49,7 @@ abstract class PlatformController { class WebController extends PlatformController { late web.HTMLIFrameElement iFrameElement; + bool _isListening = false; WebController(super.baseSrc, super.studyId) { super.frameWidget = Container(); @@ -52,7 +60,6 @@ class WebController extends PlatformController { void activate() { if (baseSrc == '') return; final key = UniqueKey(); - // debugLog("Register view with: $previewSrc"); registerViews(key); frameWidget = WebFrame(previewSrc, studyId, key: key); } @@ -64,6 +71,14 @@ class WebController extends PlatformController { ..src = previewSrc ..style.border = 'none'; + iFrameElement.onLoad.listen((_) { + onConnected?.call(); + }); + + iFrameElement.onError.listen((_) { + onError?.call('The StudyU app preview could not be loaded.'); + }); + ui.platformViewRegistry.registerViewFactory( '$studyId$key', (int viewId) => iFrameElement @@ -74,6 +89,8 @@ class WebController extends PlatformController { @override void generateUrl({String? route, String? extra, String? cmd, String? data}) { + onLoadStarted?.call(); + navigationEnabled.value = false; routeInformation = RouteInformation(route, extra, cmd, data); if (baseSrc == '') { previewSrc = ''; @@ -96,13 +113,26 @@ class WebController extends PlatformController { @override void navigate({String? route, String? extra, String? cmd, String? data}) { + if (navigationEnabled.value && cmd == null) { + routeInformation = RouteInformation(route, extra, cmd, data); + send( + jsonEncode({ + 'type': 'previewNavigate', + if (route != null) 'route': route, + if (extra != null) 'extra': extra, + if (data != null) 'data': data, + }), + ); + navigationEnabled.value = false; + return; + } + generateUrl(route: route, extra: extra, cmd: cmd, data: data); //html.IFrameElement? frame = html.document.getElementById("studyu_app_preview") as html.IFrameElement?; //if (frame != null) { // iFrameElement = frame; if (iFrameElement.src != previewSrc) { - // debugLog("*********NAVIGATE TO: $previewSrc"); iFrameElement.src = previewSrc; //iFrameElement.src = newPrev; } /* else { @@ -137,20 +167,57 @@ class WebController extends PlatformController { @override void listen() { + if (_isListening) return; + _isListening = true; web.window.onMessage.listen((event) { - final data = event.data; - if (data == 'routeFinished'.toJS) { + final data = event.data.dartify(); + if (data is String) { + try { + final parsed = jsonDecode(data); + if (parsed is Map && + parsed['type'] == 'previewStatus') { + final status = parsed['status'] as String?; + final message = parsed['message'] as String?; + switch (status) { + case 'loading': + onLoading?.call(); + return; + case 'loaded': + navigationEnabled.value = true; + onReady?.call(); + return; + case 'error': + navigationEnabled.value = false; + onError?.call( + message ?? + 'The StudyU app preview could not be opened right now.', + ); + return; + } + } + } catch (_) { + // Fall through to legacy string handling. + } + } + if (data == 'previewConnected') { + onConnected?.call(); + return; + } + if (data == 'previewReady') { + navigationEnabled.value = true; + onReady?.call(); + return; + } + if (data == 'routeFinished') { + navigationEnabled.value = true; + onReady?.call(); // debugLog("Preview route finished"); - refresh(); } }); } @override void send(String message) { - // debugLog("Send updated study to client"); - // Send to all windows for debugging - // iFrameElement.contentWindow?.postMessage(message, '*'); iFrameElement.contentWindow?.postMessage( message.toJS, (env.appUrl ?? '').toJS, diff --git a/designer_v2/lib/features/study/study_test_frame_views.dart b/designer_v2/lib/features/study/study_test_frame_views.dart index 5edc3611d..e17678ee2 100644 --- a/designer_v2/lib/features/study/study_test_frame_views.dart +++ b/designer_v2/lib/features/study/study_test_frame_views.dart @@ -7,6 +7,9 @@ import 'package:studyu_designer_v2/common_views/text_paragraph.dart'; import 'package:studyu_designer_v2/features/design/study_form_providers.dart'; import 'package:studyu_designer_v2/features/forms/form_validation.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; +import 'package:studyu_designer_v2/localization/string_hardcoded.dart'; + +enum PreviewOverlayStage { healthChecking, connecting, appLoading, error, none } class WebFrame extends StatelessWidget { final String previewSrc; @@ -54,6 +57,121 @@ class DisabledFrame extends StatelessWidget { } } +class PreviewStatusFrame extends StatelessWidget { + const PreviewStatusFrame({ + required this.icon, + required this.title, + required this.description, + this.action, + this.borderColor, + this.innerContentBackgroundColor, + super.key, + }); + + final IconData icon; + final String title; + final String description; + final Widget? action; + final Color? borderColor; + final Color? innerContentBackgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return PhoneContainer( + innerContent: Padding( + padding: const EdgeInsets.all(18.0), + child: EmptyBody( + icon: icon, + title: title, + description: description, + button: action, + ), + ), + borderColor: + borderColor ?? theme.colorScheme.secondary.withValues(alpha: 0.35), + innerContentBackgroundColor: + innerContentBackgroundColor ?? + theme.colorScheme.secondary.withValues(alpha: 0.08), + ); + } +} + +class LoadingFrame extends StatelessWidget { + const LoadingFrame({ + required this.configuredUrl, + required this.isLocalDevelopment, + required this.stage, + this.message, + super.key, + }); + + final String configuredUrl; + final bool isLocalDevelopment; + final PreviewOverlayStage stage; + final String? message; + + @override + Widget build(BuildContext context) { + final title = switch (stage) { + PreviewOverlayStage.healthChecking => 'Checking app availability', + PreviewOverlayStage.connecting => 'Connecting to app preview', + PreviewOverlayStage.appLoading => 'Loading app preview', + PreviewOverlayStage.error || PreviewOverlayStage.none => '', + }; + final description = + message ?? + switch (stage) { + PreviewOverlayStage.healthChecking => isLocalDevelopment + ? 'Checking whether the StudyU app is running at $configuredUrl.' + : 'Checking whether the participant app is reachable at $configuredUrl.', + PreviewOverlayStage.connecting => isLocalDevelopment + ? 'The local StudyU app is reachable. Establishing the iframe connection now.' + : 'The participant app is reachable. Establishing the preview connection now.', + PreviewOverlayStage.appLoading => isLocalDevelopment + ? 'The app preview is connected. The app is now loading inside the phone frame.' + : 'The app preview is connected. The participant app is now loading.', + PreviewOverlayStage.error || PreviewOverlayStage.none => '', + }; + + return PreviewStatusFrame( + icon: Icons.sync_rounded, + title: title.hardcoded, + description: description.hardcoded, + action: const Padding( + padding: EdgeInsets.only(top: 8.0), + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ), + innerContentBackgroundColor: Colors.white, + ); + } +} + +class ErrorFrame extends StatelessWidget { + const ErrorFrame({ + required this.title, + required this.message, + super.key, + }); + + final String title; + final String message; + + @override + Widget build(BuildContext context) { + return PreviewStatusFrame( + icon: Icons.warning_amber_rounded, + title: title, + description: message, + innerContentBackgroundColor: Colors.white, + ); + } +} + class PhoneContainer extends StatelessWidget { static const double defaultWidth = 300.0; static const double defaultHeight = 600.0; diff --git a/designer_v2/lib/features/study/study_test_page.dart b/designer_v2/lib/features/study/study_test_page.dart index 54400f92f..60b51cba4 100644 --- a/designer_v2/lib/features/study/study_test_page.dart +++ b/designer_v2/lib/features/study/study_test_page.dart @@ -30,22 +30,11 @@ class StudyTestScreen extends StudyPageWidget { final frameController = ref.watch( studyTestPlatformControllerProvider(studyId), ); - frameController.generateUrl(); - frameController.activate(); load().then((hasHelped) { if (!hasHelped && context.mounted) { showHelp(ref, context); } }); - final interventionSelectionDisabled = - !canTest || - formViewModel - .interventionsFormViewModel - .interventionsArray - .value! - .length <= - 2; - return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -68,74 +57,102 @@ class StudyTestScreen extends StudyPageWidget { const SizedBox(height: 24.0), Text(tr.study_test_app_nav_title), const SizedBox(height: 12.0), - TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_overview), - onPressed: (!canTest) - ? null - : () { - frameController.navigate(); - }, - ), - TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_eligibility), - onPressed: (!canTest) - ? null - : () { - frameController.navigate( - route: TestAppRoutes.eligibility, - ); - }, - ), - Row( - children: [ - Tooltip( - message: interventionSelectionDisabled - ? tr.navlink_study_test_app_intervention_disabled - : '', - child: TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_intervention), - onPressed: interventionSelectionDisabled - ? null - : () { - frameController.navigate( - route: TestAppRoutes.intervention, - ); - }, - ), - ), - ], - ), - TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_consent), - onPressed: (!canTest) - ? null - : () { - frameController.navigate(route: TestAppRoutes.consent); - }, - ), - TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_journey), - onPressed: (!canTest) - ? null - : () { - frameController.navigate(route: TestAppRoutes.journey); - }, - ), - TextButton.icon( - icon: const Icon(Icons.arrow_forward), - label: Text(tr.navlink_study_test_app_dashboard), - onPressed: (!canTest) - ? null - : () { - frameController.navigate( - route: TestAppRoutes.dashboard, - ); - }, + ValueListenableBuilder( + valueListenable: frameController.navigationEnabled, + builder: (context, previewReady, child) { + final navigationEnabled = canTest && previewReady; + final interventionSelectionDisabled = + !navigationEnabled || + formViewModel + .interventionsFormViewModel + .interventionsArray + .value! + .length <= + 2; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text(tr.navlink_study_test_app_overview), + onPressed: (!navigationEnabled) + ? null + : () { + frameController.navigate(); + }, + ), + TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text(tr.navlink_study_test_app_eligibility), + onPressed: (!navigationEnabled) + ? null + : () { + frameController.navigate( + route: TestAppRoutes.eligibility, + ); + }, + ), + Row( + children: [ + Tooltip( + message: + interventionSelectionDisabled && + navigationEnabled + ? tr.navlink_study_test_app_intervention_disabled + : '', + child: TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text( + tr.navlink_study_test_app_intervention, + ), + onPressed: interventionSelectionDisabled + ? null + : () { + frameController.navigate( + route: TestAppRoutes.intervention, + ); + }, + ), + ), + ], + ), + TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text(tr.navlink_study_test_app_consent), + onPressed: (!navigationEnabled) + ? null + : () { + frameController.navigate( + route: TestAppRoutes.consent, + ); + }, + ), + TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text(tr.navlink_study_test_app_journey), + onPressed: (!navigationEnabled) + ? null + : () { + frameController.navigate( + route: TestAppRoutes.journey, + ); + }, + ), + TextButton.icon( + icon: const Icon(Icons.arrow_forward), + label: Text(tr.navlink_study_test_app_dashboard), + onPressed: (!navigationEnabled) + ? null + : () { + frameController.navigate( + route: TestAppRoutes.dashboard, + ); + }, + ), + ], + ); + }, ), ], ),