diff --git a/.circleci/config.yml b/.circleci/config.yml index 01bd693a19c..7bfaf0ec4aa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -264,9 +264,9 @@ jobs: name: Check APK size hasn't increased command: | if [[ -n "$GOOGLE_MAPS_API_KEY" ]]; then \ - ./check-size.sh 23117869 + ./check-size.sh 23300000 else - ./check-size.sh 13863000 + ./check-size.sh 14000000 fi - run: diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogUtils.kt new file mode 100644 index 00000000000..c2c6423c634 --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogUtils.kt @@ -0,0 +1,20 @@ +package org.odk.collect.androidshared.ui + +import android.content.Context +import androidx.annotation.StringRes +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +object DialogUtils { + @JvmStatic + fun show( + context: Context, + @StringRes titleRes: Int, + @StringRes messageRes: Int, + ) { + MaterialAlertDialogBuilder(context) + .setTitle(titleRes) + .setMessage(messageRes) + .setPositiveButton(org.odk.collect.strings.R.string.ok, null) + .show() + } +} diff --git a/collect_app/build.gradle b/collect_app/build.gradle index fcc5ee18a7d..55fd3006054 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -274,6 +274,7 @@ dependencies { implementation project(':db') implementation project(':open-rosa') implementation project(':mobile-device-management') + implementation project(':timedgrid') if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { implementation project(':mapbox') diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java index 119b204dd6a..b255f6c5363 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java @@ -179,6 +179,7 @@ import org.odk.collect.androidshared.system.ProcessRestoreDetector; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.FragmentFactoryBuilder; +import org.odk.collect.androidshared.ui.DialogUtils; import org.odk.collect.androidshared.ui.SnackbarUtils; import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.async.Scheduler; @@ -199,6 +200,7 @@ import org.odk.collect.settings.SettingsProvider; import org.odk.collect.settings.keys.ProjectKeys; import org.odk.collect.strings.localization.LocalizedActivity; +import org.odk.collect.timedgrid.NavigationWarning; import java.io.File; import java.util.HashMap; @@ -388,7 +390,10 @@ public void allowSwiping(boolean doSwipe) { private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording()) { + NavigationWarning navigationWarning = odkView != null ? odkView.isNavigationBlocked() : null; + if (navigationWarning != null) { + DialogUtils.show(FormFillingActivity.this, navigationWarning.getTitleRes(), navigationWarning.getMessageRes()); + } else if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording()) { // We want the user to stop recording before changing screens DialogFragmentUtils.showIfNotShowing(RecordingWarningDialogFragment.class, getSupportFragmentManager()); } else { @@ -496,6 +501,15 @@ public void changeLanguage() { public void save() { saveForm(false, InstancesDaoHelper.isInstanceComplete(getFormController()), null, true); } + }, + () -> { + NavigationWarning navigationWarning = odkView != null ? odkView.isNavigationBlocked() : null; + if (navigationWarning != null) { + DialogUtils.show(this, navigationWarning.getTitleRes(), navigationWarning.getMessageRes()); + swipeHandler.setBeenSwiped(false); + return false; + } + return true; } ); @@ -1203,6 +1217,13 @@ private void moveScreen(Direction direction) { return; } + NavigationWarning navigationWarning = odkView != null ? odkView.isNavigationBlocked() : null; + if (navigationWarning != null) { + DialogUtils.show(this, navigationWarning.getTitleRes(), navigationWarning.getMessageRes()); + swipeHandler.setBeenSwiped(false); + return; + } + if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording()) { // We want the user to stop recording before changing screens DialogFragmentUtils.showIfNotShowing(RecordingWarningDialogFragment.class, getSupportFragmentManager()); diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryMenuProvider.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryMenuProvider.kt index 973480456c0..80a7675d2ae 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryMenuProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryMenuProvider.kt @@ -34,7 +34,8 @@ class FormEntryMenuProvider( private val backgroundLocationViewModel: BackgroundLocationViewModel, private val backgroundAudioViewModel: BackgroundAudioViewModel, private val settingsProvider: SettingsProvider, - private val formEntryMenuClickListener: FormEntryMenuClickListener + private val formEntryMenuClickListener: FormEntryMenuClickListener, + private val beforeMenuItemClick: () -> Boolean ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.form_menu, menu) @@ -96,6 +97,10 @@ class FormEntryMenuProvider( return true } + if (!beforeMenuItemClick()) { + return true + } + return when (item.itemId) { R.id.menu_add_repeat -> { if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java b/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java index 0298de59968..dad3f0ad4bd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java @@ -89,6 +89,8 @@ import org.odk.collect.permissions.PermissionListener; import org.odk.collect.permissions.PermissionsProvider; import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.timedgrid.NavigationAwareWidget; +import org.odk.collect.timedgrid.NavigationWarning; import java.io.File; import java.io.Serializable; @@ -97,6 +99,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.inject.Inject; @@ -804,4 +807,15 @@ private void updateQuestions(FormEntryPrompt[] prompts) { this.questions.add(new ImmutableDisplayableQuestion(questionAfterSave)); } } + + @Nullable + public NavigationWarning isNavigationBlocked() { + return widgets.stream() + .filter(widget -> widget instanceof NavigationAwareWidget) + .map(widget -> (NavigationAwareWidget) widget) + .map(NavigationAwareWidget::shouldBlockNavigation) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt index 979039ded26..2f7d2a05b8c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt @@ -91,6 +91,7 @@ object Appearances { const val MASKED = "masked" const val COUNTER = "counter" const val MULTILINE = "multiline" + const val X_TIMED_GRID = "x-timed-grid" // Get appearance hint and clean it up so it is lower case, without the search function and never null. @JvmStatic diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt new file mode 100644 index 00000000000..4dacd9466ae --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt @@ -0,0 +1,84 @@ +package org.odk.collect.android.widgets + +import android.annotation.SuppressLint +import android.content.Context +import org.javarosa.core.model.FormIndex +import org.javarosa.core.model.IFormElement +import org.javarosa.core.model.SelectChoice +import org.javarosa.core.model.data.IAnswerData +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.activities.FormFillingActivity +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.formentry.questions.QuestionDetails +import org.odk.collect.android.widgets.items.ItemsWidgetUtils +import org.odk.collect.timedgrid.FormAnswerRefresher +import org.odk.collect.timedgrid.FormControllerFacade +import org.odk.collect.timedgrid.NavigationAwareWidget +import org.odk.collect.timedgrid.NavigationWarning +import org.odk.collect.timedgrid.TimedGridWidgetDelegate + +@SuppressLint("ViewConstructor") +class TimedGridWidget( + context: Context, + questionDetails: QuestionDetails, + dependencies: Dependencies, + formEntryViewModel: FormEntryViewModel +) : QuestionWidget(context, dependencies, questionDetails), NavigationAwareWidget { + private val widgetDelegate = TimedGridWidgetDelegate( + context, + questionDetails.prompt, + object : FormControllerFacade { + override fun getFormElements(): List? { + return formEntryViewModel.formController.getFormDef()?.children + } + + override fun getItems(): List { + return ItemsWidgetUtils.loadItemsAndHandleErrors( + this@TimedGridWidget, questionDetails.prompt, formEntryViewModel + ) + } + + override fun saveAnswer(index: FormIndex, answer: IAnswerData) { + formEntryViewModel.formController.saveOneScreenAnswer(index, answer, false) + } + }, + object : FormAnswerRefresher { + override fun refreshAnswer(index: FormIndex) { + val activity = context as? FormFillingActivity ?: return + val odkView = activity.currentViewIfODKView ?: return + + val widget = odkView.widgets + .filterIsInstance() + .firstOrNull { it.formEntryPrompt.index == index } + ?: return + + widget.apply { + setDisplayValueFromModel() + widgetValueChanged() + showAnswerContainer() + } + } + } + ) { + widgetValueChanged() + } + + init { + render() + } + + override fun onCreateWidgetView(context: Context, prompt: FormEntryPrompt, answerFontSize: Int) = widgetDelegate.onCreateWidgetView(this) + + override fun getAnswer() = widgetDelegate.getAnswer() + + override fun clearAnswer() {} + + override fun setOnLongClickListener(l: OnLongClickListener?) {} + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + widgetDelegate.onDetachedFromWindow() + } + + override fun shouldBlockNavigation(): NavigationWarning? = widgetDelegate.shouldBlockNavigation() +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java index 4f0ea149db5..aa1eaf42e38 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java @@ -265,6 +265,8 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions questionWidget = new LabelWidget(activity, questionDetails, formEntryViewModel, dependencies); } else if (appearance.contains(Appearances.IMAGE_MAP)) { questionWidget = new SelectMultiImageMapWidget(activity, questionDetails, formEntryViewModel, dependencies); + } else if (appearance.startsWith(Appearances.X_TIMED_GRID)) { + questionWidget = new TimedGridWidget(activity, questionDetails, dependencies, formEntryViewModel); } else { questionWidget = new SelectMultiWidget(activity, questionDetails, formEntryViewModel, dependencies); } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryMenuProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryMenuProviderTest.kt index d122fe54fc2..512cadff628 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryMenuProviderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryMenuProviderTest.kt @@ -64,7 +64,7 @@ class FormEntryMenuProviderTest { backgroundAudioViewModel, settingsProvider, formEntryMenuClickListener - ) + ) { true } @Test fun onPrepare_inRepeatQuestion_showsAddRepeat() { diff --git a/settings.gradle b/settings.gradle index c98143a1fce..6505a1a7f4b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,6 +41,7 @@ include ':web-page' include ':db' include ':open-rosa' include ':mobile-device-management' +include ':timedgrid' apply from: 'secrets.gradle' if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { diff --git a/timedgrid/.gitignore b/timedgrid/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/timedgrid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/timedgrid/build.gradle.kts b/timedgrid/build.gradle.kts new file mode 100644 index 00000000000..85162f0b135 --- /dev/null +++ b/timedgrid/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +apply(from = "../config/quality.gradle") + +android { + namespace = "org.odk.collect.timedgrid" + + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + buildFeatures { + viewBinding = true + buildConfig = true + } +} + +dependencies { + coreLibraryDesugaring(libs.desugar) + + implementation(project(":androidshared")) + implementation(project(":strings")) + + implementation(libs.androidxFragmentKtx) + implementation(libs.androidMaterial) + implementation(libs.androidxAppcompat) + implementation(libs.androidFlexbox) + implementation(libs.androidxLifecycleViewmodelKtx) + implementation(libs.javarosa) { + exclude(group = "joda-time") + exclude(group = "org.hamcrest", module = "hamcrest-all") + } +} diff --git a/timedgrid/src/main/AndroidManifest.xml b/timedgrid/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..a5918e68abc --- /dev/null +++ b/timedgrid/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt new file mode 100644 index 00000000000..ac3867cfa30 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt @@ -0,0 +1,13 @@ +package org.odk.collect.timedgrid + +enum class AssessmentType( + private val rendererFactory: () -> TimedGridRenderer +) { + LETTERS(::CommonTimedGridRenderer), + WORDS(::CommonTimedGridRenderer), + NUMBERS(::CommonTimedGridRenderer), + ARITHMETIC(::CommonTimedGridRenderer), + READING({ CommonTimedGridRenderer(showRowNumbers = false) }); + + fun createRenderer(): TimedGridRenderer = rendererFactory() +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt new file mode 100644 index 00000000000..b5df28d5682 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt @@ -0,0 +1,157 @@ +package org.odk.collect.timedgrid + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.odk.collect.timedgrid.databinding.TimedGridBinding + +/** + * Common renderer for timed grid modes that share the same UI and interaction pattern: + * LETTERS, WORDS, NUMBERS, ARITHMETIC, READING, X. + */ +class CommonTimedGridRenderer( + private val showRowNumbers: Boolean = true +) : TimedGridRenderer { + + private lateinit var binding: TimedGridBinding + private lateinit var grid: TimedGridWidgetLayout + private var derivedState: TimedGridState = TimedGridState.NEW + + override fun inflateView(inflater: LayoutInflater, parent: ViewGroup): View { + binding = TimedGridBinding.inflate(inflater, parent, false) + return binding.root + } + + override fun bind( + root: View, + configuration: TimedGridWidgetConfiguration, + items: List, + onStart: () -> Unit, + onComplete: () -> Unit, + onFinish: () -> Unit, + onTogglePause: () -> Unit, + ) { + binding.buttonStart.setOnClickListener { onStart() } + binding.buttonComplete.setOnClickListener { onComplete() } + binding.buttonTimer.setOnClickListener { onTogglePause() } + binding.buttonFinish.setOnClickListener { onFinish() } + + grid = TimedGridWidgetLayout( + layoutInflater = LayoutInflater.from(binding.root.context), + containerView = binding.containerRows, + columns = configuration.columns, + rowsPerPage = configuration.rowsPerPage, + type = configuration.type, + showRowNumbers = showRowNumbers, + strictMode = configuration.strict, + endAfterConsecutive = configuration.endAfterConsecutive, + onComplete = onComplete, + allItems = items + ) + + // Pagination buttons + binding.buttonPrev.setOnClickListener { + grid.prevPage() + updatePaginationButtons() + } + binding.buttonNext.setOnClickListener { + grid.nextPage() + updatePaginationButtons() + } + + // Enable Finish when assessor picks last item. + grid.setLastItemSelectionListener { _ -> + binding.buttonFinish.isEnabled = true + } + + updatePaginationButtons() + } + + private fun updatePaginationButtons() { + binding.buttonPrev.visibility = if (grid.isFirstPage()) View.GONE else View.VISIBLE + binding.buttonNext.visibility = if (grid.isLastPage()) View.GONE else View.VISIBLE + binding.buttonComplete.visibility = if (grid.isLastPage() && derivedState == TimedGridState.IN_PROGRESS) View.VISIBLE else View.GONE + } + + override fun updateUIForState(state: TimedGridState) { + derivedState = state + + when (state) { + TimedGridState.NEW -> { + binding.buttonStart.visibility = View.VISIBLE + binding.buttonTimer.visibility = View.GONE + binding.buttonFinish.visibility = View.GONE + binding.buttonComplete.visibility = View.GONE + + grid.setGridEnabled(false) + } + + TimedGridState.IN_PROGRESS -> { + binding.buttonStart.visibility = View.GONE + binding.buttonTimer.visibility = View.VISIBLE + binding.buttonFinish.visibility = View.GONE + binding.buttonComplete.visibility = View.VISIBLE + + grid.setGridEnabled(true) + updatePaginationButtons() + } + + TimedGridState.PAUSED -> { + binding.buttonStart.visibility = View.GONE + binding.buttonTimer.visibility = View.VISIBLE + binding.buttonFinish.visibility = View.GONE + binding.buttonComplete.visibility = View.GONE + + grid.setGridEnabled(false) + updatePaginationButtons() + } + + TimedGridState.COMPLETED_NO_LAST_ITEM -> { + binding.buttonStart.visibility = View.GONE + binding.buttonTimer.visibility = View.GONE + binding.buttonFinish.visibility = View.VISIBLE + binding.buttonComplete.visibility = View.GONE + + val lastAttempted = getLastSelectedLastItemValue() + + if (lastAttempted == null) { + // Disable Finish until last-item selected + binding.buttonFinish.isEnabled = false + + // Allow selecting last-item after the last toggled (or any if none) + val lastToggled = grid.getLastToggledValue() + grid.setEnabledFrom(lastToggled) + } + } + + TimedGridState.COMPLETED -> { + binding.buttonStart.visibility = View.GONE + binding.buttonTimer.visibility = View.GONE + binding.buttonFinish.visibility = View.GONE + binding.buttonComplete.visibility = View.GONE + + // Final visuals: wrong answers + highlight last item + val wrongAnswers = grid.getToggledAnswers() + val lastSelected = grid.getLastSelectedLastItemValue() + grid.revealFinalSelection(wrongAnswers, lastSelected) + } + } + } + + override fun updateTimer(timeString: String) { + binding.buttonTimer.text = timeString + } + + override fun getLastSelectedLastItemValue(): String? = + grid.getLastSelectedLastItemValue() + + override fun getToggledAnswers(): Set = + grid.getToggledAnswers() + + override fun firstLineAllIncorrect(): Boolean = grid.firstLineSelected() + + override fun restoreAnswers(toggled: Set, last: String?) { + grid.setToggledAnswers(toggled) + grid.setLastSelectedLastItemValue(last) + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt new file mode 100644 index 00000000000..5ff6026bffb --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt @@ -0,0 +1,17 @@ +package org.odk.collect.timedgrid + +enum class FinishType(val code: Int) { + /** User confirms and picks last attempted item manually */ + CONFIRM_AND_PICK(1), + + /** User confirms, auto-pick last item in list */ + CONFIRM_AND_AUTO_PICK(2), + + /** No confirm, auto-pick last item in list */ + AUTO_PICK_NO_CONFIRM(3); + + companion object { + fun fromInt(value: Int): FinishType = + entries.find { it.code == value } ?: CONFIRM_AND_PICK + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt new file mode 100644 index 00000000000..50b514dfff8 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt @@ -0,0 +1,16 @@ +package org.odk.collect.timedgrid + +import androidx.annotation.StringRes + +interface NavigationAwareWidget { + + /** + * Returns NavigationBlock if navigation should be blocked, or null if navigation is allowed. + */ + fun shouldBlockNavigation(): NavigationWarning? +} + +data class NavigationWarning( + @StringRes val titleRes: Int, + @StringRes val messageRes: Int +) diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt new file mode 100644 index 00000000000..08e1e5dd5e0 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt @@ -0,0 +1,70 @@ +package org.odk.collect.timedgrid + +import android.os.CountDownTimer + +class PausableCountDownTimer { + private var millisRemaining: Long = 0 + private lateinit var onTick: (millisUntilFinished: Long) -> Unit + private lateinit var onFinish: () -> Unit + + private var timer: CountDownTimer? = null + private var isPaused: Boolean = true + + fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { + this.onTick = onTick + this.onFinish = onFinish + } + + fun setUpDuration(millisRemaining: Long) { + this.millisRemaining = millisRemaining + } + + /** + * Starts or resumes the countdown. + * @return This PausableCountDownTimer. + */ + @Synchronized + fun start(): PausableCountDownTimer { + if (isPaused) { + isPaused = false + timer = object : CountDownTimer(millisRemaining, 1000) { + override fun onTick(millisUntilFinished: Long) { + millisRemaining = millisUntilFinished + this@PausableCountDownTimer.onTick(millisRemaining) + } + + override fun onFinish() { + millisRemaining = 0 + this@PausableCountDownTimer.onFinish() + } + }.start() + } + return this + } + + /** + * Pauses the countdown. + */ + @Synchronized + fun pause() { + if (!isPaused) { + timer?.cancel() + timer = null + } + isPaused = true + } + + /** + * Cancels the countdown and resets the timer. + */ + @Synchronized + fun cancel() { + timer?.cancel() + timer = null + isPaused = true + } + + fun getMillisRemaining(): Long { + return millisRemaining + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt new file mode 100644 index 00000000000..b184924118e --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt @@ -0,0 +1,31 @@ +package org.odk.collect.timedgrid + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +interface TimedGridRenderer { + fun inflateView(inflater: LayoutInflater, parent: ViewGroup): View + + fun bind( + root: View, + configuration: TimedGridWidgetConfiguration, + items: List, + onStart: () -> Unit, + onComplete: () -> Unit, + onFinish: () -> Unit, + onTogglePause: () -> Unit + ) + + fun updateUIForState(state: TimedGridState) + + fun updateTimer(timeString: String) + + fun getLastSelectedLastItemValue(): String? + + fun getToggledAnswers(): Set + + fun firstLineAllIncorrect(): Boolean + + fun restoreAnswers(toggled: Set, last: String?) {} +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt new file mode 100644 index 00000000000..fdeb60ef4e5 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt @@ -0,0 +1,6 @@ +package org.odk.collect.timedgrid + +// Lifecycle states for the timed grid widget. +enum class TimedGridState { + NEW, IN_PROGRESS, PAUSED, COMPLETED_NO_LAST_ITEM, COMPLETED +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt new file mode 100644 index 00000000000..54f37629c93 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt @@ -0,0 +1,46 @@ +package org.odk.collect.timedgrid + +data class TimedGridSummary( + val secondsRemaining: Int, + val attemptedCount: Int, + val incorrectCount: Int, + val correctCount: Int, + val firstLineAllIncorrect: Boolean, + val sentencesPassed: Int, + val correctItems: String, + val unansweredItems: String, + val punctuationCount: Int, +) { + data class Builder( + var secondsRemaining: Int = 0, + var attemptedCount: Int = 0, + var incorrectCount: Int = 0, + var correctCount: Int = 0, + var firstLineAllIncorrect: Boolean = false, + var sentencesPassed: Int = 0, + var correctItems: String = "", + var unansweredItems: String = "", + var punctuationCount: Int = 0, + ) { + fun secondsRemaining(secondsRemaining: Int) = apply { this.secondsRemaining = secondsRemaining } + fun attemptedCount(attemptedCount: Int) = apply { this.attemptedCount = attemptedCount } + fun incorrectCount(incorrectCount: Int) = apply { this.incorrectCount = incorrectCount } + fun correctCount(correctCount: Int) = apply { this.correctCount = correctCount } + fun firstLineAllIncorrect(firstLineAllIncorrect: Boolean) = apply { this.firstLineAllIncorrect = firstLineAllIncorrect } + fun sentencesPassed(sentencesPassed: Int) = apply { this.sentencesPassed = sentencesPassed } + fun correctItems(correctItems: String) = apply { this.correctItems = correctItems } + fun unansweredItems(unansweredItems: String) = apply { this.unansweredItems = unansweredItems } + fun punctuationCount(punctuationCount: Int) = apply { this.punctuationCount = punctuationCount } + fun build() = TimedGridSummary( + secondsRemaining, + attemptedCount, + incorrectCount, + correctCount, + firstLineAllIncorrect, + sentencesPassed, + correctItems, + unansweredItems, + punctuationCount + ) + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt new file mode 100644 index 00000000000..95f264eb257 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -0,0 +1,83 @@ +package org.odk.collect.timedgrid + +import org.javarosa.core.model.FormIndex +import org.javarosa.core.model.GroupDef +import org.javarosa.core.model.IFormElement +import org.javarosa.core.model.QuestionDef +import org.javarosa.core.model.SelectChoice +import org.javarosa.core.model.data.BooleanData +import org.javarosa.core.model.data.IAnswerData +import org.javarosa.core.model.data.IntegerData +import org.javarosa.core.model.data.StringData +import org.javarosa.core.model.instance.TreeReference +import org.javarosa.form.api.FormEntryPrompt + +class TimedGridSummaryAnswerCreator( + private val formEntryPrompt: FormEntryPrompt, + private val formControllerFacade: FormControllerFacade, + private val formAnswerRefresher: FormAnswerRefresher +) { + companion object { + val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""x-timed-grid-answer\((.+),(.+)\)""") + } + + fun answerSummaryQuestions(summary: TimedGridSummary) { + val timedGridQuestionId = formEntryPrompt.index.reference.toString(false) + + forEachFormQuestionDef(formControllerFacade.getFormElements()) { questionIndex, questionDef -> + val summaryQuestionMatch = + SUMMARY_QUESTION_APPEARANCE_REGEX.find(questionDef.appearanceAttr ?: "") + + if (summaryQuestionMatch != null) { + val referencedQuestion = summaryQuestionMatch.groupValues[1].trim() + val metadataName = summaryQuestionMatch.groupValues[2].trim() + + if (referencedQuestion == timedGridQuestionId) { + val answer = getSummaryAnswer(metadataName, summary) + formControllerFacade.saveAnswer(questionIndex, answer) + formAnswerRefresher.refreshAnswer(questionIndex) + } + } + } + } + + private fun forEachFormQuestionDef( + iFormElements: Iterable?, + currentFormIndex: FormIndex? = null, + action: (formIndex: FormIndex, IFormElement) -> Unit + ) { + iFormElements?.forEachIndexed { index, formElement -> + val nextLevelIndex = FormIndex(index, formElement.bind.reference as TreeReference) + if (formElement is GroupDef) { + forEachFormQuestionDef(formElement.children, nextLevelIndex, action) + } else if (formElement is QuestionDef) { + action(FormIndex(nextLevelIndex, currentFormIndex), formElement) + } + } + } + + private fun getSummaryAnswer(metadataName: String, summary: TimedGridSummary): IAnswerData { + return when (metadataName) { + "time-remaining" -> IntegerData(summary.secondsRemaining) + "attempted-count" -> IntegerData(summary.attemptedCount) + "incorrect-count" -> IntegerData(summary.incorrectCount) + "correct-count" -> IntegerData(summary.correctCount) + "first-line-all-incorrect" -> BooleanData(summary.firstLineAllIncorrect) + "sentences-passed" -> IntegerData(summary.sentencesPassed) + "correct-items" -> StringData(summary.correctItems) + "unanswered-items" -> StringData(summary.unansweredItems) + "punctuation-count" -> IntegerData(summary.punctuationCount) + else -> throw IllegalArgumentException("Unknown metadata name: $metadataName") + } + } +} + +interface FormControllerFacade { + fun getFormElements(): List? + fun getItems(): List + fun saveAnswer(index: FormIndex, answer: IAnswerData) +} + +interface FormAnswerRefresher { + fun refreshAnswer(index: FormIndex) +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt new file mode 100644 index 00000000000..bd2ff79ee77 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt @@ -0,0 +1,29 @@ +package org.odk.collect.timedgrid + +import androidx.lifecycle.ViewModel +import org.javarosa.core.model.FormIndex + +class TimedGridViewModel : ViewModel() { + + data class TimedGridTimerState( + val millisRemaining: Long, + val state: TimedGridState, + val toggledAnswers: Set = emptySet(), + val lastAttempted: String? = null + ) + + private val timedGridStates = mutableMapOf() + + fun saveTimedGridState(index: FormIndex, state: TimedGridTimerState) { + timedGridStates[index] = state + } + + fun getTimedGridState(index: FormIndex): TimedGridTimerState? { + return timedGridStates[index] + } + + override fun onCleared() { + super.onCleared() + timedGridStates.clear() + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt new file mode 100644 index 00000000000..f21dc8fc81e --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt @@ -0,0 +1,193 @@ +package org.odk.collect.timedgrid + +import androidx.core.text.isDigitsOnly +import org.javarosa.form.api.FormEntryPrompt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +data class TimedGridWidgetConfiguration( + /** The type of assessment being conducted (e.g., LETTERS, WORDS). */ + val type: AssessmentType, + /** The number of columns to display in the grid. */ + val columns: Int, + /** The number of rows to display per page in the grid. */ + val rowsPerPage: Int, + /** The total duration of the timed assessment. */ + val duration: Duration, + /** The number of consecutive incorrect answers after which the assessment should end early. */ + val endAfterConsecutive: Int, + /** Enable strict mode that disables selection after time expires and enforces early-end behavior. */ + val strict: Boolean, + /** Allows to pause timer during assessment. */ + val allowPause: Boolean, + /** A special value stored when all answers are marked correctly. */ + val allAnsweredCorrectly: String, + /** Determines how the task finishes */ + val finish: FinishType +) { + class Builder { + private var type: AssessmentType = AssessmentType.LETTERS + private var columns: Int = 10 + private var rowsPerPage: Int = 5 + private var duration: Duration = 120.seconds + private var endAfterConsecutive: Int = 5 + private var strict: Boolean = false + private var allowPause: Boolean = true + private var allAnsweredCorrectly: String = "999" + private var finish: FinishType = FinishType.CONFIRM_AND_PICK + + fun type(type: AssessmentType) = apply { this.type = type } + fun columns(columns: Int) = apply { this.columns = columns } + fun rowsPerPage(rowsPerPage: Int) = apply { this.rowsPerPage = rowsPerPage } + fun duration(duration: Duration) = apply { this.duration = duration } + fun endAfterConsecutive(endAfterConsecutive: Int) = apply { this.endAfterConsecutive = endAfterConsecutive } + fun strict(strict: Boolean) = apply { this.strict = strict } + fun allowPause(allowPause: Boolean) = apply { this.allowPause = allowPause } + fun allAnsweredCorrectly(allAnsweredCorrectly: String) = apply { this.allAnsweredCorrectly = allAnsweredCorrectly } + fun finish(finish: FinishType) = apply { this.finish = finish } + + fun build(): TimedGridWidgetConfiguration { + return TimedGridWidgetConfiguration( + type, + columns, + rowsPerPage, + duration, + endAfterConsecutive, + strict, + allowPause, + allAnsweredCorrectly, + finish + ) + } + } + + companion object { + private object Keys { + const val TYPE = "type" + const val COLUMNS = "columns" + const val PAGE_ROWS = "page-rows" + const val DURATION = "duration" + const val END_AFTER = "end-after" + const val STRICT = "strict" + const val PAUSE = "pause" + const val ALL_ANSWERED = "all-answered" + const val FINISH = "finish" + } + + fun fromPrompt(prompt: FormEntryPrompt): TimedGridWidgetConfiguration { + val params = extractParams(prompt.appearanceHint) + + val detectedType: AssessmentType + var columnsOverride: Int? = null + + val typeParam = params[Keys.TYPE] + if (typeParam != null) { + if (typeParam.isDigitsOnly()) { + detectedType = AssessmentType.LETTERS + columnsOverride = typeParam.toInt() + } else { + detectedType = try { + AssessmentType.valueOf(typeParam.uppercase()) + } catch (e: IllegalArgumentException) { + AssessmentType.LETTERS + } + } + } else { + detectedType = AssessmentType.LETTERS + } + + val configBuilder = Builder().apply { + type(detectedType) + when (detectedType) { + AssessmentType.LETTERS -> { + columns(10) + rowsPerPage(5) + duration(120.seconds) + endAfterConsecutive(5) + strict(false) + allowPause(false) + allAnsweredCorrectly("999") + finish(FinishType.CONFIRM_AND_PICK) + } + AssessmentType.WORDS -> { + columns(5) + rowsPerPage(5) + duration(120.seconds) + endAfterConsecutive(5) + strict(false) + allowPause(false) + allAnsweredCorrectly("999") + finish(FinishType.CONFIRM_AND_PICK) + } + AssessmentType.READING -> { + columns(1) + rowsPerPage(1) + duration(120.seconds) + endAfterConsecutive(5) + strict(false) + allowPause(false) + allAnsweredCorrectly("999") + finish(FinishType.CONFIRM_AND_PICK) + } + AssessmentType.NUMBERS -> { + columns(5) + rowsPerPage(5) + duration(120.seconds) + endAfterConsecutive(5) + strict(false) + allowPause(false) + allAnsweredCorrectly("999") + finish(FinishType.CONFIRM_AND_PICK) + } + AssessmentType.ARITHMETIC -> { + columns(2) + rowsPerPage(5) + duration(120.seconds) + endAfterConsecutive(5) + strict(false) + allowPause(false) + allAnsweredCorrectly("999") + finish(FinishType.CONFIRM_AND_PICK) + } + } + } + + columnsOverride?.let { configBuilder.columns(it) } + + if (columnsOverride == null) { + params[Keys.COLUMNS]?.let { configBuilder.columns(it.toInt()) } + } + params[Keys.FINISH]?.let { + val finishValue = it.toIntOrNull() + if (finishValue != null) { + configBuilder.finish(FinishType.fromInt(finishValue)) + } + } + params[Keys.PAGE_ROWS]?.let { configBuilder.rowsPerPage(it.toInt()) } + params[Keys.DURATION]?.let { configBuilder.duration(it.toInt().seconds) } + params[Keys.END_AFTER]?.let { configBuilder.endAfterConsecutive(it.toInt()) } + params[Keys.STRICT]?.let { configBuilder.strict(it.toBoolean()) } + params[Keys.PAUSE]?.let { + val normalized = it.trim().lowercase() + val allowPause = normalized == "true" || normalized == "1" + configBuilder.allowPause(allowPause) + } + params[Keys.ALL_ANSWERED]?.let { configBuilder.allAnsweredCorrectly(it) } + + return configBuilder.build() + } + } +} + +private fun extractParams(appearance: String): Map { + val params = mutableMapOf() + // Allow for optional whitespace around '=' and support hyphenated keys + val regex = Regex("([\\w-]+)\\s*=\\s*([^,)]+)") + val matches = regex.findAll(appearance) + + for (matchResult in matches) { + val (key, value) = matchResult.destructured + params[key.trim()] = value.trim() + } + return params +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt new file mode 100644 index 00000000000..0f551a59741 --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt @@ -0,0 +1,290 @@ +package org.odk.collect.timedgrid + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.javarosa.core.model.data.IAnswerData +import org.javarosa.core.model.data.MultipleItemsData +import org.javarosa.core.model.data.helper.Selection +import org.javarosa.form.api.FormEntryPrompt +import kotlin.collections.ifEmpty +import kotlin.collections.toMutableList +import kotlin.time.Duration.Companion.milliseconds + +class TimedGridWidgetDelegate( + private val context: Context, + private val formEntryPrompt: FormEntryPrompt, + formControllerFacade: FormControllerFacade, + formAnswerRefresher: FormAnswerRefresher, + private val widgetValueChanged: () -> Unit +) { + private val viewModel = ViewModelProvider(context as ViewModelStoreOwner)[TimedGridViewModel::class.java] + private val items = formControllerFacade.getItems() + private val timer = PausableCountDownTimer() + + // Parsed prompt configuration (type, duration, etc.). + private val config = TimedGridWidgetConfiguration.fromPrompt( + formEntryPrompt + ).also { + timer.setUpDuration(it.duration.inWholeMilliseconds) + } + + private val summaryAnswerCreator = TimedGridSummaryAnswerCreator(formEntryPrompt, formControllerFacade, formAnswerRefresher) + + // Filtered items to display in the grid. + private val gridItems = items + .filter { it.value != config.allAnsweredCorrectly } + .map { GridItem(it.value, formEntryPrompt.getSelectChoiceText(it)) } + + // Renderer chosen by the assessment type. + private val renderer = config.type.createRenderer() + + // Current widget state and timer reference. + private var state = TimedGridState.NEW + + private val summaryBuilder = TimedGridSummary.Builder() + + fun onCreateWidgetView(parent: ViewGroup): View { + val inflater = LayoutInflater.from(context) + val root = renderer.inflateView(inflater, parent) + renderer.bind( + root, + config, + gridItems, + onStart = { startAssessment() }, + onComplete = { onEarlyFinishPress() }, + onFinish = { finishAssessment() }, + onTogglePause = { if (state == TimedGridState.PAUSED) resumeAssessment() else pauseAssessment() } + ) + + timer.setUpListeners( + this::onTimerTick, + this::onTimerFinish + ) + + viewModel.getTimedGridState(formEntryPrompt.index)?.let { saved -> + state = saved.state + timer.cancel() + timer.setUpDuration(saved.millisRemaining) + + renderer.restoreAnswers(saved.toggledAnswers, saved.lastAttempted) + + if (state == TimedGridState.IN_PROGRESS) { + timer.start() + } else if (state == TimedGridState.PAUSED) { + val secondsRemaining = saved.millisRemaining.milliseconds.inWholeSeconds + val timeLeftText = context.getString( + R.string.timed_grid_time_left, + secondsRemaining + ) + renderer.updateTimer(timeLeftText) + summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + } + + renderer.updateUIForState(state) + } ?: run { + readSavedItems() + renderer.updateUIForState(state) + } + + return root + } + + /** + * Returns all selected (wrong) answers followed by last attempted answer. + * If no answers ware selected, a special value is used instead. + * [selected answers, last attempted answer] where selected answers can be real selected answers or special value for "all correct". + */ + fun getAnswer(): IAnswerData { + val selectedValues = renderer.getToggledAnswers().ifEmpty { + setOf(config.allAnsweredCorrectly) + }.toMutableList() + + renderer.getLastSelectedLastItemValue()?.let { + selectedValues.add(it) + } + + val choicesByValue = items.associateBy { it.value } + return MultipleItemsData(selectedValues.map { selectedValue -> Selection(choicesByValue[selectedValue]) }) + } + + fun onDetachedFromWindow() { + // Ensure state is saved when widget is removed + saveTimerState() + timer.cancel() + } + + private fun onTimerTick(millisUntilFinished: Long) { + val secondsRemaining = millisUntilFinished.milliseconds.inWholeSeconds + val timeLeftText = context.getString( + R.string.timed_grid_time_left, + secondsRemaining + ) + renderer.updateTimer(timeLeftText) + summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + } + + private fun onTimerFinish() { + val timeLeftText = context.getString( + R.string.timed_grid_time_left, + 0 + ) + renderer.updateTimer(timeLeftText) + if (config.strict) { + completeAssessment() + } + } + + private fun startAssessment() { + renderer.restoreAnswers(emptySet(), null) + state = TimedGridState.IN_PROGRESS + renderer.updateUIForState(state) + timer.start() + } + + private fun pauseAssessment() { + if (!config.allowPause) { + return + } + + timer.pause() + state = TimedGridState.PAUSED + saveTimerState() + renderer.updateUIForState(state) + } + + private fun resumeAssessment() { + if (!config.allowPause) { + return + } + + timer.start() + state = TimedGridState.IN_PROGRESS + renderer.updateUIForState(state) + } + + private fun onEarlyFinishPress() { + when (config.finish) { + FinishType.CONFIRM_AND_PICK -> { + showConfirmFinishDialog { completeAssessment() } + } + FinishType.CONFIRM_AND_AUTO_PICK -> { + showConfirmFinishDialog { + completeAssessment(true) + } + } + FinishType.AUTO_PICK_NO_CONFIRM -> { + completeAssessment(true) + } + } + } + + private fun showConfirmFinishDialog(onConfirm: () -> Unit) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.early_finish_title) + .setMessage(R.string.early_finish_message) + .setPositiveButton(R.string.end_test) { _, _ -> + onConfirm() + } + .setNegativeButton(R.string.continue_test, null) + .show() + } + + private fun readSavedItems() { + val selections = (formEntryPrompt.answerValue?.value as? List) + ?: emptyList() + + val choiceByValue = items.associateBy { it.value } + val mapped = selections.mapNotNull { choiceByValue[it.value] } + + if (mapped.isNotEmpty()) { + val toggledSet = mapped.take(mapped.size - 1) + .mapNotNull { it.value } + .toSet() + + val lastValue = mapped.last().value + renderer.restoreAnswers( + toggledSet, + lastValue.takeIf { it != config.allAnsweredCorrectly } + ) + } + } + + private fun completeAssessment(forceAutopick: Boolean = false) { + timer.cancel() + state = TimedGridState.COMPLETED_NO_LAST_ITEM + saveTimerState() + renderer.updateUIForState(state) + + // If the last item is toggled, auto-pick it as last attempted + val lastItemValue = gridItems.lastOrNull()?.value + if (forceAutopick || + (lastItemValue != null && renderer.getToggledAnswers().contains(lastItemValue)) + ) { + autoPickLastItem() + finishAssessment() + } + } + + private fun finishAssessment() { + state = TimedGridState.COMPLETED + saveTimerState() + renderer.updateUIForState(state) + + // summaryBuilder.secondsRemaining updated by timer + summaryBuilder.attemptedCount(gridItems.indexOfFirst { gridItem -> gridItem.value == renderer.getLastSelectedLastItemValue() } + 1) + summaryBuilder.incorrectCount(renderer.getToggledAnswers().size) + summaryBuilder.correctCount(summaryBuilder.attemptedCount - summaryBuilder.incorrectCount) + summaryBuilder.firstLineAllIncorrect(renderer.firstLineAllIncorrect()) + summaryBuilder.sentencesPassed(gridItems.subList(0, summaryBuilder.attemptedCount) + .count { gridItem -> Regex("^\\p{Punct}+$").matches(gridItem.text) }) + summaryBuilder.correctItems(gridItems.subList(0, summaryBuilder.attemptedCount) + .filter { !renderer.getToggledAnswers().contains(it.value) } + .joinToString { it.text }) + summaryBuilder.unansweredItems(gridItems.subList( + summaryBuilder.attemptedCount, gridItems.size + ).joinToString { it.text }) + summaryBuilder.punctuationCount(gridItems.count { Regex("^\\p{Punct}+$").matches(it.text) }) + summaryAnswerCreator.answerSummaryQuestions(summaryBuilder.build()) + + widgetValueChanged() + } + + private fun autoPickLastItem() { + val lastItemValue = gridItems.lastOrNull()?.value + if (lastItemValue != null) { + renderer.restoreAnswers(renderer.getToggledAnswers(), lastItemValue) + } + } + + private fun saveTimerState() { + viewModel.saveTimedGridState( + formEntryPrompt.index, + TimedGridViewModel.TimedGridTimerState( + millisRemaining = timer.getMillisRemaining(), + state = state, + toggledAnswers = renderer.getToggledAnswers(), + lastAttempted = renderer.getLastSelectedLastItemValue() + ) + ) + } + + fun shouldBlockNavigation(): NavigationWarning? { + val shouldNavigationBeBlocked = state == TimedGridState.IN_PROGRESS || + state == TimedGridState.PAUSED || + state == TimedGridState.COMPLETED_NO_LAST_ITEM + + return if (shouldNavigationBeBlocked) { + NavigationWarning( + titleRes = R.string.assessment, + messageRes = R.string.assessment_warning + ) + } else { + null + } + } +} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt new file mode 100644 index 00000000000..3d7d24b928d --- /dev/null +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt @@ -0,0 +1,435 @@ +package org.odk.collect.timedgrid + +import android.content.res.ColorStateList +import android.graphics.Paint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.androidshared.system.ContextUtils.getThemeAttributeValue +import org.odk.collect.timedgrid.databinding.TimedGridItemButtonBinding +import org.odk.collect.timedgrid.databinding.TimedGridItemRowBinding + +data class GridItem(val value: String, val text: String) + +private enum class ButtonFinalState { DEFAULT, WRONG, LAST } + +/** + * Compact grid used by the timed-grid widget. + * + * Responsibilities: + * - render items as MaterialButtons + * - track toggled (attempted/wrong) answers + * - support enabling a suffix of buttons for "last-item" selection + * - notify on last-item selection + * - reveal final visuals (wrong + last) + */ +class TimedGridWidgetLayout( + private val layoutInflater: LayoutInflater, + private val containerView: ViewGroup, + private val columns: Int, + private val rowsPerPage: Int, + private val type: AssessmentType, + private val showRowNumbers: Boolean, + private val strictMode: Boolean, + private val endAfterConsecutive: Int, + private val onComplete: () -> Unit, + private val allItems: List +) { + private val toggledAnswers = mutableSetOf() + private val buttons = mutableListOf() + private val valueForButton = mutableMapOf() + + // original visuals so we can restore them + private val originalTextColor = mutableMapOf() + private val originalBackgroundTint = mutableMapOf() + + private var lastItemSelectionListener: ((String) -> Unit)? = null + + // marker for last-item mode (start index from setEnabledFrom). null = not in mode + private var lastItemModeIndex: Int? = null + + private var lastSelectedLastItemValue: String? = null + + // Render all items in a single row container instead of chunking into multiple numbered rows + private val shouldRenderAsSingleRow = columns == 1 && type == AssessmentType.READING + + private val orderedValues = mutableListOf() + private val valueToIndexMap = mutableMapOf() + + /** + * Determines whether the user has chosen to continue the assessment + * after exceeding the `end-after` consecutive mistakes limit. + */ + private var userChoseToContinueAfterLimit = false + + // Pagination + private var currentPage = 0 + private var totalRows = 0 + + private companion object { + const val MARGIN_PERCENT_PER_BUTTON = 0.0025f + const val SMALL_SCREEN_DP_THRESHOLD = 600 + const val MULTIPLE_ROWS_COLUMN_THRESHOLD = 5 + val ONLY_PUNCTUATION_REGEX = Regex("^\\p{Punct}+$") + } + + init { + allItems.forEachIndexed { index, item -> + orderedValues.add(item.value) + valueToIndexMap[item.value] = index + } + + buildAllRows() + + showPage(0) + setGridEnabled(false) + } + + private fun buildAllRows() { + val rows = if (shouldRenderAsSingleRow) { + listOf(allItems) + } else { + allItems.chunked(columns) + } + totalRows = rows.size + + rows.forEachIndexed { rowIndex, rowItems -> + val rowBinding = TimedGridItemRowBinding.inflate(layoutInflater, containerView, false) + + if (showRowNumbers) { + rowBinding.textviewRowNumber.text = containerView.context.getString( + R.string.timed_grid_row_number, + rowIndex + 1 + ) + } else { + rowBinding.textviewRowNumber.visibility = View.GONE + } + + populateRow(layoutInflater, rowBinding, rowItems) + containerView.addView(rowBinding.root) + } + } + + private fun populateRow( + layoutInflater: LayoutInflater, + rowBinding: TimedGridItemRowBinding, + rowItems: List + ) { + val flexBasisPercent = calculateFlexBasisPercent() + + for (item in rowItems) { + val button = TimedGridItemButtonBinding.inflate( + layoutInflater, rowBinding.containerWordButtons, false + ).root + + button.text = item.text + buttons.add(button) + valueForButton[button] = item.value + + originalTextColor[button] = button.currentTextColor + originalBackgroundTint[button] = button.backgroundTintList + + button.setOnClickListener { handleButtonClick(button, item) } + + val lp = button.layoutParams as com.google.android.flexbox.FlexboxLayout.LayoutParams + if (shouldRenderAsSingleRow) { + lp.flexGrow = 1f + button.setPadding(24, 0, 24, 0) + } else { + lp.flexBasisPercent = flexBasisPercent + button.layoutParams = lp + } + + rowBinding.containerWordButtons.addView(button) + } + } + + private fun showPage(pageIndex: Int) { + currentPage = pageIndex + val startRow = pageIndex * rowsPerPage + val endRow = startRow + rowsPerPage + + for (i in 0 until containerView.childCount) { + val rowView = containerView.getChildAt(i) + rowView.visibility = if (i in startRow until endRow) View.VISIBLE else View.GONE + } + } + + fun nextPage() { + if (currentPage < getTotalPages() - 1) { + showPage(currentPage + 1) + } + } + + fun prevPage() { + if (currentPage > 0) { + showPage(currentPage - 1) + } + } + + fun isFirstPage() = currentPage == 0 + fun isLastPage() = currentPage == getTotalPages() - 1 + private fun getTotalPages() = (totalRows + rowsPerPage - 1) / rowsPerPage + + fun setGridEnabled(isEnabled: Boolean) { + lastItemModeIndex = null + buttons.forEach { button -> + val isPunctuationInReading = type == AssessmentType.READING && ONLY_PUNCTUATION_REGEX.matches(button.text) + button.isEnabled = isEnabled && !isPunctuationInReading + } + } + + fun setEnabledFrom(valueFrom: String?) { + val idx = buttons.indexOfFirst { valueForButton[it] == valueFrom } + val startIndex = idx + 1 + lastItemModeIndex = startIndex.coerceAtMost(buttons.size) + buttons.forEachIndexed { i, btn -> btn.isEnabled = i >= startIndex } + } + + fun setLastItemSelectionListener(listener: ((String) -> Unit)?) { + lastItemSelectionListener = listener + } + + fun getLastToggledValue(): String? { + for (i in buttons.size - 1 downTo 0) { + val value = valueForButton[buttons[i]] + if (value != null && value in toggledAnswers) return value + } + return null + } + + fun getLastSelectedLastItemValue(): String? = lastSelectedLastItemValue + fun getToggledAnswers(): Set = toggledAnswers.toSet() + + fun revealFinalSelection(wrongAnswers: Set, lastItemValue: String?) { + buttons.forEach { btn -> + val value = valueForButton[btn] + val state = when { + value != null && value == lastItemValue -> ButtonFinalState.LAST + value != null && value in wrongAnswers -> ButtonFinalState.WRONG + else -> ButtonFinalState.DEFAULT + } + applyFinalVisual(btn, state) + btn.isEnabled = false + } + lastSelectedLastItemValue = lastItemValue + } + + private fun calculateFlexBasisPercent(): Float { + var desiredCols = columns + if (isSmallScreen() && type == AssessmentType.LETTERS && columns > MULTIPLE_ROWS_COLUMN_THRESHOLD) { + desiredCols /= 2 + } + + if (showRowNumbers) { + desiredCols += 1 + } + + return (1f - desiredCols * MARGIN_PERCENT_PER_BUTTON) / desiredCols + } + + private fun isSmallScreen(): Boolean { + val displayMetrics = containerView.context.resources.displayMetrics + val dpWidth = displayMetrics.widthPixels / displayMetrics.density + return dpWidth < SMALL_SCREEN_DP_THRESHOLD + } + + private fun handleButtonClick(button: MaterialButton, item: GridItem) { + val inLastItemMode = lastItemModeIndex != null && button.isEnabled + + if (inLastItemMode) { + MaterialAlertDialogBuilder(button.context) + .setTitle(R.string.last_attempted_item) + .setMessage(R.string.confirm_last_attempted_item) + .setPositiveButton(org.odk.collect.strings.R.string.yes) { _, _ -> + // User confirmed -> pick last item: keep other visuals intact + lastSelectedLastItemValue = item.value + applyFinalVisual(button, ButtonFinalState.LAST) + lastItemSelectionListener?.invoke(item.value) + lastItemModeIndex = null + buttons.forEach { it.isEnabled = false } + } + .setNegativeButton(org.odk.collect.strings.R.string.no) { _, _ -> + // User declined -> remain in last-item mode so they can pick a different one. + } + .show() + + return + } + + // Normal toggle + val isSelected = item.value in toggledAnswers + + if (isSelected) { + toggledAnswers.remove(item.value) + } else { + toggledAnswers.add(item.value) + + val currentIndex = valueToIndexMap[item.value] ?: return + + val backwardCount = countConsecutiveWrongFrom(currentIndex - 1, -1) + val forwardCount = countConsecutiveWrongFrom(currentIndex + 1, 1) + + val totalStreak = backwardCount + 1 + forwardCount + + if (endAfterConsecutive in 1..totalStreak) { + updateButtonVisuals(button, !isSelected) + + if (strictMode) { + showLimitExceededDialog(button) + } else { + if (userChoseToContinueAfterLimit) { + return + } + showConsecutiveMistakesChoiceDialog(button) + } + return + } + } + + updateButtonVisuals(button, !isSelected) + } + + private fun showLimitExceededDialog(button: MaterialButton) { + MaterialAlertDialogBuilder(button.context) + .setTitle(R.string.test_ended_early_title) + .setMessage( + button.context.getString( + R.string.test_ended_early_message, + endAfterConsecutive + ) + ) + .setPositiveButton(org.odk.collect.strings.R.string.ok) { _, _ -> + onComplete.invoke() + } + .setCancelable(false) + .show() + } + + private fun showConsecutiveMistakesChoiceDialog(button: MaterialButton) { + MaterialAlertDialogBuilder(button.context) + .setTitle(R.string.consecutive_mistakes_title) + .setMessage( + button.context.getString( + R.string.consecutive_mistakes_message, + endAfterConsecutive + ) + ) + .setPositiveButton(R.string.end_test) { _, _ -> + onComplete.invoke() + } + .setNegativeButton(R.string.continue_test) { _, _ -> + userChoseToContinueAfterLimit = true + } + .show() + } + + private fun countConsecutiveWrongFrom(startIndex: Int, step: Int): Int { + var count = 0 + var index = startIndex + + while (true) { + val value = orderedValues.getOrNull(index) ?: break + val item = allItems.getOrNull(index) ?: break + + // Skip punctuation items (don’t count them, don’t break streak) in READING assessments + if (type == AssessmentType.READING && ONLY_PUNCTUATION_REGEX.matches(item.text)) { + index += step + continue + } + + if (value in toggledAnswers) { + count++ + index += step + } else { + break + } + } + + return count + } + + private fun updateButtonVisuals(button: MaterialButton, isSelected: Boolean) { + button.isSelected = isSelected + if (isSelected) { + button.paintFlags = button.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + button.paintFlags = button.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + } + + fun setToggledAnswers(values: Set) { + toggledAnswers.clear() + toggledAnswers.addAll(values) + + // Refresh visuals for currently rendered buttons + for (btn in buttons) { + val v = valueForButton[btn] + val selected = v != null && v in toggledAnswers + btn.isSelected = selected + btn.paintFlags = if (selected) { + btn.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + btn.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + } + } + + /** + * Set last-selected last-item value (visual only). Pass null to clear. + */ + fun setLastSelectedLastItemValue(value: String?) { + // Clear previous + lastSelectedLastItemValue?.let { prev -> + val prevBtn = buttons.find { valueForButton[it] == prev } + prevBtn?.let { applyFinalVisual(it, ButtonFinalState.DEFAULT) } + } + + lastSelectedLastItemValue = value + value?.let { v -> + val btn = buttons.find { valueForButton[it] == v } + btn?.let { applyFinalVisual(it, ButtonFinalState.LAST) } + } + } + + fun firstLineSelected(): Boolean { + if (buttons.size < columns || type == AssessmentType.READING) { + return false + } + val firstLineButtons = buttons.take(columns) + return firstLineButtons.all { it.isSelected } + } + + /** + * Apply final look for a button: + * - DEFAULT: restore originals + * - WRONG: selected + strike-through + error text color + * - LAST: selected + no strike-through + green background tint + */ + private fun applyFinalVisual(button: MaterialButton, state: ButtonFinalState) { + when (state) { + ButtonFinalState.DEFAULT -> { + originalBackgroundTint[button]?.let { button.backgroundTintList = it } + originalTextColor[button]?.let { button.setTextColor(it) } + button.paintFlags = button.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + button.isSelected = false + } + ButtonFinalState.WRONG -> { + button.isSelected = true + button.paintFlags = button.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + button.setTextColor(getThemeAttributeValue(button.context, androidx.appcompat.R.attr.colorError)) + } + ButtonFinalState.LAST -> { + button.isSelected = true + button.paintFlags = button.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + button.backgroundTintList = ContextCompat.getColorStateList( + button.context, + R.color.timedGridButtonGreenLastAnswer + ) + } + } + } +} diff --git a/timedgrid/src/main/res/color/timed_grid_button_tint_selector.xml b/timedgrid/src/main/res/color/timed_grid_button_tint_selector.xml new file mode 100644 index 00000000000..513f7da9618 --- /dev/null +++ b/timedgrid/src/main/res/color/timed_grid_button_tint_selector.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/timedgrid/src/main/res/drawable/row_number_background.xml b/timedgrid/src/main/res/drawable/row_number_background.xml new file mode 100644 index 00000000000..d72631ad018 --- /dev/null +++ b/timedgrid/src/main/res/drawable/row_number_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/timedgrid/src/main/res/layout/timed_grid.xml b/timedgrid/src/main/res/layout/timed_grid.xml new file mode 100644 index 00000000000..64eae0fd081 --- /dev/null +++ b/timedgrid/src/main/res/layout/timed_grid.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/timedgrid/src/main/res/layout/timed_grid_item_button.xml b/timedgrid/src/main/res/layout/timed_grid_item_button.xml new file mode 100644 index 00000000000..5dcc92aac2f --- /dev/null +++ b/timedgrid/src/main/res/layout/timed_grid_item_button.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/timedgrid/src/main/res/layout/timed_grid_item_row.xml b/timedgrid/src/main/res/layout/timed_grid_item_row.xml new file mode 100644 index 00000000000..7a30da8f207 --- /dev/null +++ b/timedgrid/src/main/res/layout/timed_grid_item_row.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/timedgrid/src/main/res/values/colors.xml b/timedgrid/src/main/res/values/colors.xml new file mode 100644 index 00000000000..2dcbc3a25f5 --- /dev/null +++ b/timedgrid/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #2196F3 + #FF9800 + #E0E0E0 + #FFEB3B + #4CAF50 + \ No newline at end of file diff --git a/timedgrid/src/main/res/values/dimens.xml b/timedgrid/src/main/res/values/dimens.xml new file mode 100644 index 00000000000..28f5abbd228 --- /dev/null +++ b/timedgrid/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 4dp + 8dp + \ No newline at end of file diff --git a/timedgrid/src/main/res/values/strings.xml b/timedgrid/src/main/res/values/strings.xml new file mode 100644 index 00000000000..694b50364e1 --- /dev/null +++ b/timedgrid/src/main/res/values/strings.xml @@ -0,0 +1,40 @@ + + + + Last Attempted Item + Do you want to confirm the last attempted item? + + Test Ended Early + + You have reached the limit of %d consecutive wrong answers. + + + + Consecutive Mistakes + + You have made %d consecutive mistakes. Do you want to end the test or continue? + + + + End Test + Continue + + + Early Finish + Do you want to end the test now? + + Start + Time Left: %d + Test Complete + Prev Page + Next Page + Early Finish + (%d) + + Assessment… + You must finish assessment before leaving this screen. + \ No newline at end of file diff --git a/timedgrid/src/main/res/values/styles.xml b/timedgrid/src/main/res/values/styles.xml new file mode 100644 index 00000000000..b9badf3b8a9 --- /dev/null +++ b/timedgrid/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file