diff --git a/app/assets/locales/android_translatable_strings.txt b/app/assets/locales/android_translatable_strings.txt index c5c9a103b3..6c5cd0df6d 100644 --- a/app/assets/locales/android_translatable_strings.txt +++ b/app/assets/locales/android_translatable_strings.txt @@ -166,6 +166,7 @@ sync.recover.started=Recovering local DB State. Please do not turn off the app! form.entry.segfault=There was an unrecoverable error during form entry! If the problem persists, seek CommCare Support form.entry.processing=Processing your Form form.entry.processing.title=Processing Form +form.entry.loading.next.question=Loading next question… form.entry.complete.save.success=Form successfully completed form.entry.incomplete.save.success=Form saved as incomplete form.entry.save.error=Sorry, form save failed. Please contact CommCare Support to look into the issue. diff --git a/app/res/layout/screen_form_entry.xml b/app/res/layout/screen_form_entry.xml index 1ebf0dd240..619c3395f4 100644 --- a/app/res/layout/screen_form_entry.xml +++ b/app/res/layout/screen_form_entry.xml @@ -212,6 +212,35 @@ android:background="@color/transclucent_nearly_solid_grey" android:clickable="true" android:visibility="gone"/> + + + + + + + + + + diff --git a/app/src/org/commcare/activities/AsyncFormNavigator.kt b/app/src/org/commcare/activities/AsyncFormNavigator.kt new file mode 100644 index 0000000000..c145b50252 --- /dev/null +++ b/app/src/org/commcare/activities/AsyncFormNavigator.kt @@ -0,0 +1,51 @@ +package org.commcare.activities + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Runs form-navigation stepping on a background thread and delivers the + * resulting renderable event back on the main thread. + */ +class AsyncFormNavigator( + private val lifecycleOwner: LifecycleOwner, + private val stepWork: StepWork +) { + + fun interface StepWork { + fun step(): NavResult + } + + fun interface StartCallback { + fun onStart() + } + + fun interface ResultCallback { + fun onResult(result: NavResult) + } + + private var job: Job? = null + private var currentNavId: Long = 0 + + fun navigate(resuming: Boolean, onStart: StartCallback, onResult: ResultCallback) { + val navId = ++currentNavId + onStart.onStart() + job = lifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.Default) { stepWork.step() } + if (navId != currentNavId) { + return@launch + } + onResult.onResult(result) + } + } + + fun cancel() { + currentNavId++ + job?.cancel() + job = null + } +} \ No newline at end of file diff --git a/app/src/org/commcare/activities/FormEntryActivity.java b/app/src/org/commcare/activities/FormEntryActivity.java index 25e0aa0d96..bf66491309 100644 --- a/app/src/org/commcare/activities/FormEntryActivity.java +++ b/app/src/org/commcare/activities/FormEntryActivity.java @@ -911,6 +911,8 @@ public void taskCancelled() { protected void onPause() { super.onPause(); + uiController.cancelNavigation(); + if (!isFinishing() && uiController.questionsView != null && currentPromptIsQuestion()) { saveAnswersForCurrentScreen(false); } diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index 37bd673d66..0832f92623 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -81,9 +81,12 @@ public class FormEntryActivityUIController implements CommCareActivityUIControll private BlockingActionsManager blockingActionsManager; private boolean formRelevanciesUpdateInProgress = false; + private boolean navigationInFlight = false; private static final String KEY_LAST_CHANGED_WIDGET = "index-of-last-changed-widget"; private TextView finishText; + private FormLoadingOverlay formLoadingOverlay; + private AsyncFormNavigator asyncFormNavigator; enum AnimationType { LEFT, RIGHT, FADE @@ -144,6 +147,13 @@ public void setupUI() { mViewPane = activity.findViewById(R.id.form_entry_pane); + formLoadingOverlay = new FormLoadingOverlay( + activity.findViewById(R.id.form_entry_loading_overlay), + activity.findViewById(R.id.form_entry_loading_label), + Localization.get("form.entry.loading.next.question")); + asyncFormNavigator = new AsyncFormNavigator( + activity, + this::stepToRenderableEvent); activity.requestMajorLayoutUpdates(); @@ -417,71 +427,90 @@ private void showNextView(boolean resuming) { // Any info stored about the last changed widget is useless when we move to a new view resetLastChangedWidget(); - if (FormEntryActivity.mFormController.getEvent() != FormEntryController.EVENT_END_OF_FORM) { - int event; + if (FormEntryActivity.mFormController.getEvent() == FormEntryController.EVENT_END_OF_FORM) { + return; + } - try { - group_skip: - do { - event = FormEntryActivity.mFormController.stepToNextEvent(FormEntryController.STEP_OVER_GROUP); - switch (event) { - case FormEntryController.EVENT_QUESTION: - QuestionsView next = createView(); - if (!resuming) { - showView(next, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); - } else { - showView(next, AnimationType.FADE, false); - } - break group_skip; - case FormEntryController.EVENT_END_OF_FORM: - // auto-advance questions might advance past the last form quesion - // In special case when questionsView is null (there is no question), - // to avoid exit dialog without saving option, shown from refreshCurrentView(), - // save and exit method called. - if (questionsView != null) { - refreshCurrentView(); - } else { - activity.triggerUserFormComplete(); - } - break group_skip; - case FormEntryController.EVENT_PROMPT_NEW_REPEAT: - createRepeatDialog(); - break group_skip; - case FormEntryController.EVENT_GROUP: - //We only hit this event if we're at the _opening_ of a field - //list, so it seems totally fine to do it this way, technically - //though this should test whether the index is the field list - //host. - if (FormEntryActivity.mFormController.indexIsInFieldList() - && FormEntryActivity.mFormController.getQuestionPrompts().length != 0) { - QuestionsView nextGroupView = createView(); - if (!resuming) { - showView(nextGroupView, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); - } else { - showView(nextGroupView, AnimationType.FADE, false); - } - break group_skip; - } - // otherwise it's not a field-list group, so just skip it - break; - case FormEntryController.EVENT_REPEAT: - Log.i(TAG, "repeat: " + FormEntryActivity.mFormController.getFormIndex().getReference()); - // skip repeats - break; - case FormEntryController.EVENT_REPEAT_JUNCTURE: - Log.i(TAG, "repeat juncture: " - + FormEntryActivity.mFormController.getFormIndex().getReference()); - // skip repeat junctures until we implement them - break; - default: - Log.w(TAG, - "JavaRosa added a new EVENT type and didn't tell us... shame on them."); - break; - } - } while (event != FormEntryController.EVENT_END_OF_FORM); - } catch (XPathException e) { - new UserfacingErrorHandling<>().logErrorAndShowDialog(activity, e, FormEntryConstants.EXIT); + navigationInFlight = true; + asyncFormNavigator.navigate( + resuming, + formLoadingOverlay::show, + result -> { + navigationInFlight = false; + formLoadingOverlay.hide(); + renderNavResult(result, resuming); + }); + } + + /** + * Walks the form forward via {@link FormEntryController#stepToNextEvent} until a renderable + * event is reached. Pure controller-state access; no view creation or UI side effects. + */ + private NavResult stepToRenderableEvent() { + try { + while (true) { + int event = FormEntryActivity.mFormController.stepToNextEvent(FormEntryController.STEP_OVER_GROUP); + switch (event) { + case FormEntryController.EVENT_QUESTION: + return NavResult.Question.INSTANCE; + case FormEntryController.EVENT_END_OF_FORM: + return NavResult.EndOfForm.INSTANCE; + case FormEntryController.EVENT_PROMPT_NEW_REPEAT: + return NavResult.PromptNewRepeat.INSTANCE; + case FormEntryController.EVENT_GROUP: + //We only hit this event if we're at the _opening_ of a field + //list, so it seems totally fine to do it this way, technically + //though this should test whether the index is the field list + //host. + if (FormEntryActivity.mFormController.indexIsInFieldList() + && FormEntryActivity.mFormController.getQuestionPrompts().length != 0) { + return NavResult.FieldListGroup.INSTANCE; + } + // otherwise it's not a field-list group, so just skip it + break; + case FormEntryController.EVENT_REPEAT: + Log.i(TAG, "repeat: " + FormEntryActivity.mFormController.getFormIndex().getReference()); + // skip repeats + break; + case FormEntryController.EVENT_REPEAT_JUNCTURE: + Log.i(TAG, "repeat juncture: " + + FormEntryActivity.mFormController.getFormIndex().getReference()); + // skip repeat junctures until we implement them + break; + default: + Log.w(TAG, + "JavaRosa added a new EVENT type and didn't tell us... shame on them."); + break; + } } + } catch (XPathException e) { + return new NavResult.Error(e); + } + } + + private void renderNavResult(NavResult result, boolean resuming) { + if (result instanceof NavResult.Question || result instanceof NavResult.FieldListGroup) { + QuestionsView next = createView(); + if (!resuming) { + showView(next, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); + } else { + showView(next, AnimationType.FADE, false); + } + } else if (result instanceof NavResult.PromptNewRepeat) { + createRepeatDialog(); + } else if (result instanceof NavResult.EndOfForm) { + // auto-advance questions might advance past the last form question + // In special case when questionsView is null (there is no question), + // to avoid exit dialog without saving option, shown from refreshCurrentView(), + // save and exit method called. + if (questionsView != null) { + refreshCurrentView(); + } else { + activity.triggerUserFormComplete(); + } + } else if (result instanceof NavResult.Error) { + new UserfacingErrorHandling<>().logErrorAndShowDialog( + activity, ((NavResult.Error)result).getException(), FormEntryConstants.EXIT); } } @@ -598,7 +627,16 @@ protected void next() { } protected boolean shouldIgnoreNavigationAction() { - return blockingActionsManager.isBlocked(); + return blockingActionsManager.isBlocked() || navigationInFlight; + } + + protected void cancelNavigation() { + if (!navigationInFlight) { + return; + } + asyncFormNavigator.cancel(); + navigationInFlight = false; + formLoadingOverlay.hide(); } protected boolean shouldIgnoreSwipeAction() { diff --git a/app/src/org/commcare/activities/FormLoadingOverlay.kt b/app/src/org/commcare/activities/FormLoadingOverlay.kt new file mode 100644 index 0000000000..0fc8a34c33 --- /dev/null +++ b/app/src/org/commcare/activities/FormLoadingOverlay.kt @@ -0,0 +1,40 @@ +package org.commcare.activities + +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.TextView + +/** + * Wraps the form-entry loading overlay view. [show] posts a delayed + * visibility change so fast navigations don't flash the overlay; [hide] + * cancels any pending show and hides the view immediately. + */ +class FormLoadingOverlay @JvmOverloads constructor( + private val overlayView: View, + labelView: TextView, + labelText: String, + private val showDelayMillis: Long = DEFAULT_SHOW_DELAY_MILLIS +) { + + private val handler = Handler(Looper.getMainLooper()) + private val showRunnable = Runnable { overlayView.visibility = View.VISIBLE } + + init { + labelView.text = labelText + } + + fun show() { + handler.removeCallbacks(showRunnable) + handler.postDelayed(showRunnable, showDelayMillis) + } + + fun hide() { + handler.removeCallbacks(showRunnable) + overlayView.visibility = View.GONE + } + + companion object { + const val DEFAULT_SHOW_DELAY_MILLIS: Long = 150 + } +} diff --git a/app/src/org/commcare/activities/NavResult.kt b/app/src/org/commcare/activities/NavResult.kt new file mode 100644 index 0000000000..675b2e7957 --- /dev/null +++ b/app/src/org/commcare/activities/NavResult.kt @@ -0,0 +1,14 @@ +package org.commcare.activities + +import org.javarosa.xpath.XPathException + +/** + * Outcome of stepping the form to the next renderable event. + */ +sealed class NavResult { + object Question : NavResult() + object FieldListGroup : NavResult() + object PromptNewRepeat : NavResult() + object EndOfForm : NavResult() + data class Error(val exception: XPathException) : NavResult() +} \ No newline at end of file