diff --git a/designer_v2/lib/common_views/form_buttons.dart b/designer_v2/lib/common_views/form_buttons.dart index a0d616882..69a8b685f 100644 --- a/designer_v2/lib/common_views/form_buttons.dart +++ b/designer_v2/lib/common_views/form_buttons.dart @@ -57,11 +57,7 @@ List buildFormButtons(FormViewModel formViewModel, FormMode formMode) { // enable re-rendering based on form validation status builder: (context, form, child) { return retainSizeInAppBar( - DismissButton( - onPressed: () => formViewModel.cancel().then((_) { - if (context.mounted) Navigator.maybePop(context); - }), - ), + DismissButton(onPressed: () => Navigator.maybePop(context)), ); }, ), diff --git a/designer_v2/lib/common_views/sidesheet/sidesheet.dart b/designer_v2/lib/common_views/sidesheet/sidesheet.dart index 383a648a1..c7b37a687 100644 --- a/designer_v2/lib/common_views/sidesheet/sidesheet.dart +++ b/designer_v2/lib/common_views/sidesheet/sidesheet.dart @@ -248,6 +248,7 @@ Future showModalSideSheet({ String? barrierLabel = "Sidesheet", bool useRootNavigator = true, RouteSettings? routeSettings, + Widget Function(Widget)? wrapRoute, }) { assert(!barrierDismissible || barrierLabel != null); return showGeneralDialog( @@ -259,16 +260,19 @@ Future showModalSideSheet({ useRootNavigator: useRootNavigator, routeSettings: routeSettings, context: context, - pageBuilder: (BuildContext context, _, _) => Sidesheet( - body: body, - tabs: tabs, - wrapContent: wrapContent, - width: width, - withCloseButton: withCloseButton, - ignoreAppBar: ignoreAppBar, - actionButtons: actionButtons, - titleText: title, - ), + pageBuilder: (BuildContext context, _, _) { + final sidesheet = Sidesheet( + body: body, + tabs: tabs, + wrapContent: wrapContent, + width: width, + withCloseButton: withCloseButton, + ignoreAppBar: ignoreAppBar, + actionButtons: actionButtons, + titleText: title, + ); + return wrapRoute != null ? wrapRoute(sidesheet) : sidesheet; + }, transitionBuilder: (_, animation, _, child) { return SlideTransition( position: Tween( diff --git a/designer_v2/lib/common_views/sidesheet/sidesheet_form.dart b/designer_v2/lib/common_views/sidesheet/sidesheet_form.dart index 91c208ed5..4ce261023 100644 --- a/designer_v2/lib/common_views/sidesheet/sidesheet_form.dart +++ b/designer_v2/lib/common_views/sidesheet/sidesheet_form.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:reactive_forms/reactive_forms.dart'; import 'package:studyu_designer_v2/common_views/form_buttons.dart'; @@ -5,6 +6,7 @@ import 'package:studyu_designer_v2/common_views/form_scaffold.dart'; import 'package:studyu_designer_v2/common_views/navbar_tabbed.dart'; import 'package:studyu_designer_v2/common_views/sidesheet/sidesheet.dart'; import 'package:studyu_designer_v2/features/forms/form_view_model.dart'; +import 'package:studyu_designer_v2/features/forms/unsaved_changes_dialog.dart'; import 'package:studyu_designer_v2/theme.dart'; class FormSideSheetTab extends NavbarTab { @@ -20,6 +22,108 @@ class FormSideSheetTab extends NavbarTab { FormViewBuilder formViewBuilder; } +class _FormSidesheetPopEntry extends StatefulWidget { + const _FormSidesheetPopEntry({ + required this.formViewModel, + required this.child, + }); + + final T formViewModel; + final Widget child; + + @override + State<_FormSidesheetPopEntry> createState() => + _FormSidesheetPopEntryState(); +} + +class _FormSidesheetPopEntryState + extends State<_FormSidesheetPopEntry> + implements PopEntry { + ModalRoute? _route; + final ValueNotifier _canPopNotifier = ValueNotifier(false); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route?.unregisterPopEntry(this); + _route = ModalRoute.of(context); + debugPrint('[PopEntry] registering with route: ${_route.runtimeType}'); + _route?.registerPopEntry(this); + } + + @override + void dispose() { + _route?.unregisterPopEntry(this); + _route = null; + _canPopNotifier.dispose(); + super.dispose(); + } + + @override + ValueListenable get canPopNotifier => _canPopNotifier; + + @override + void onPopInvokedWithResult(bool didPop, Object? result) { + debugPrint( + '[PopEntry] onPopInvokedWithResult didPop=$didPop canPop=${_canPopNotifier.value}', + ); + if (didPop) { + return; + } + _handleDismiss(); + } + + @override + void onPopInvoked(bool didPop) { + // Deprecated + } + + Future _handleDismiss() async { + debugPrint( + '[PopEntry] _handleDismiss isDirty=${widget.formViewModel.isDirty}', + ); + + if (!widget.formViewModel.isDirty) { + await widget.formViewModel.cancel(); + if (mounted) { + _canPopNotifier.value = true; + + // CHANGE HERE: Wait for the frame to finish so Navigator is unlocked + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + }); + } + return; + } + + // Dirty state logic + final shouldDiscard = await showDialog( + context: context, + barrierColor: ThemeConfig.modalBarrierColor(Theme.of(context)), + builder: (context) => const UnsavedChangesDialog(), + ); + + if (shouldDiscard == true && mounted) { + await widget.formViewModel.cancel(); + if (mounted && Navigator.of(context).canPop()) { + _canPopNotifier.value = true; + + // CHANGE HERE: Wait for the frame to finish + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + }); + } + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + Future showFormSideSheet({ required BuildContext context, required T formViewModel, @@ -29,7 +133,7 @@ Future showFormSideSheet({ double? width, bool withCloseButton = false, bool ignoreAppBar = true, - bool barrierDismissible = false, + bool barrierDismissible = true, Color? barrierColor, }) { barrierColor ??= ThemeConfig.modalBarrierColor(Theme.of(context)); @@ -65,5 +169,7 @@ Future showFormSideSheet({ ignoreAppBar: ignoreAppBar, barrierDismissible: barrierDismissible, barrierColor: barrierColor, + wrapRoute: (sidesheet) => + _FormSidesheetPopEntry(formViewModel: formViewModel, child: sidesheet), ); } diff --git a/designer_v2/lib/features/forms/form_view_model.dart b/designer_v2/lib/features/forms/form_view_model.dart index d26eebed9..f7f277df5 100644 --- a/designer_v2/lib/features/forms/form_view_model.dart +++ b/designer_v2/lib/features/forms/form_view_model.dart @@ -65,6 +65,9 @@ abstract class FormViewModel implements IFormGroupController { _restoreControlsFromFormData(); _formModeUpdated(); _applyValidationSet(validationSet); + Future.microtask(() { + finalizeInitializationBaseline(); + }); if (autosave) { // Push to event queue to avoid listening to update events @@ -73,6 +76,11 @@ abstract class FormViewModel implements IFormGroupController { } } + void finalizeInitializationBaseline() { + prevFormValue = _getFullFormValue(); + form.markAsPristine(); + } + T? get formData => _formData; set formData(T? formData) => _setFormData(formData); T? _formData; @@ -133,17 +141,10 @@ abstract class FormViewModel implements IFormGroupController { /// values are initialized in [setControlsFrom] (controls that are set /// programmatically are incorrectly marked as dirty without any user input). bool get isDirty { - _rememberDefaultControlStates(); - - for (final control in form.controls.values) { - control.markAsEnabled(emitEvent: false, updateParent: false); - } - final isEqual = jsonEncode(prevFormValue) == jsonEncode(form.value); + if (prevFormValue == null) return false; - for (final control in form.controls.values) { - control.markAsEnabled(emitEvent: false, updateParent: false); - } - _restoreControlStates(emitEvent: false, updateParent: false); + final currentFormValue = _getFullFormValue(); + final isEqual = jsonEncode(prevFormValue) == jsonEncode(currentFormValue); return !isEqual; } @@ -169,10 +170,29 @@ abstract class FormViewModel implements IFormGroupController { void _setFormData(T? formData) { _formData = formData; if (formData != null) { - setControlsFrom(formData); // update [form] controls automatically + setControlsFrom(formData); } - prevFormValue = {...form.value}; - form.updateValueAndValidity(); + finalizeInitializationBaseline(); + } + + JsonMap _getFullFormValue() { + _rememberDefaultControlStates(); + + // 1. Temporarily enable all controls + for (final control in form.controls.values) { + control.markAsEnabled(emitEvent: false, updateParent: false); + } + // 2. CRITICAL: Force the FormGroup to rebuild its value cache! + form.updateValueAndValidity(updateParent: false, emitEvent: false); + + // 3. Deep copy the full value + final fullValue = jsonDecode(jsonEncode(form.value)) as JsonMap; + + // 4. Restore original states and rebuild the cache again + _restoreControlStates(emitEvent: false, updateParent: false); + form.updateValueAndValidity(updateParent: false, emitEvent: false); + + return fullValue; } void _rememberDefaultControlStates() { diff --git a/designer_v2/lib/features/recruit/invite_code_form_controller.dart b/designer_v2/lib/features/recruit/invite_code_form_controller.dart index 9915deb38..650d20c47 100644 --- a/designer_v2/lib/features/recruit/invite_code_form_controller.dart +++ b/designer_v2/lib/features/recruit/invite_code_form_controller.dart @@ -117,6 +117,7 @@ class InviteCodeFormViewModel extends FormViewModel { @override void initControls() { regenerateCode(); // initialize randomly + prevFormValue = {...form.value}; } // - Validation diff --git a/flutter_common/lib/envs/.env.dev b/flutter_common/lib/envs/.env.dev index 3738415b9..3b8957f2a 100644 --- a/flutter_common/lib/envs/.env.dev +++ b/flutter_common/lib/envs/.env.dev @@ -1,5 +1,5 @@ STUDYU_SUPABASE_URLS=https://studyu-02.dhc-lab.hpi.de -STUDYU_SUPABASE_PUBLIC_ANON_KEY=eyJhbGciOiAiSFMyNTYiLCJ0eXAiOiAiSldUIn0.eyJyb2xlIjogImFub24iLCJpc3MiOiAic3VwYWJhc2UiLCJpYXQiOiAxNzY1MDEzMTIyLCJleHAiOiAxNzk2NTQ5MTIyfQ.d8cyoFZ_E3ymZyKEjZCP1VktnlMFntOaaosSwjjPFMY +STUDYU_SUPABASE_PUBLIC_ANON_KEY=eyJhbGciOiAiSFMyNTYiLCJ0eXAiOiAiSldUIn0.eyJyb2xlIjogImFub24iLCJpc3MiOiAic3VwYWJhc2UiLCJpYXQiOiAxNzU3Mzk3MDI5LCJleHAiOiAxNzg4OTMzMDI5fQ.BQwOLCX6h7RBvZ0xTdt2o-3Hw6YJxalI7EuCVwH69yo STUDYU_PROJECT_GENERATOR_URL= STUDYU_APP_URL=https://app.dev.studyu.health STUDYU_DESIGNER_URL=https://designer.dev.studyu.health