From 4d4b4109a785169606ab77f4186699ff1db52271 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Wed, 18 Feb 2026 23:22:38 +0100 Subject: [PATCH 01/14] Add new module for experimental features --- collect_app/build.gradle | 1 + experimental/.gitignore | 1 + experimental/build.gradle.kts | 42 +++++++++++++++++++++++ experimental/src/main/AndroidManifest.xml | 4 +++ gradle/libs.versions.toml | 2 ++ settings.gradle | 1 + 6 files changed, 51 insertions(+) create mode 100644 experimental/.gitignore create mode 100644 experimental/build.gradle.kts create mode 100644 experimental/src/main/AndroidManifest.xml diff --git a/collect_app/build.gradle b/collect_app/build.gradle index fcc5ee18a7d..e6bbea85d10 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(':experimental') if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { implementation project(':mapbox') diff --git a/experimental/.gitignore b/experimental/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/experimental/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/experimental/build.gradle.kts b/experimental/build.gradle.kts new file mode 100644 index 00000000000..52758a92a79 --- /dev/null +++ b/experimental/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = "org.odk.collect.experimental" + + 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 { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + buildFeatures { + viewBinding = true + buildConfig = true + } +} + +dependencies { +} diff --git a/experimental/src/main/AndroidManifest.xml b/experimental/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..a5918e68abc --- /dev/null +++ b/experimental/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c374771848..7e213c94283 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ glide = "5.0.5" camerax = "1.4.2" # Newer versions require minSdkVersion >= 23 espresso = "3.7.0" kotlin = "2.2.20" +junit = "1.3.0" [libraries] firebaseCrashlyticsGradle = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version = "3.0.6" } @@ -117,6 +118,7 @@ okhttp3Mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", hamcrest = { group = "org.hamcrest", name = "hamcrest", version = "3.0" } robolectric = { group = "org.robolectric", name = "robolectric", version = "4.16" } uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" } +ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } [plugins] androidApplication = { id = "com.android.application" } diff --git a/settings.gradle b/settings.gradle index c98143a1fce..cd9a0f96cd5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,6 +41,7 @@ include ':web-page' include ':db' include ':open-rosa' include ':mobile-device-management' +include ':experimental' apply from: 'secrets.gradle' if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { From cb083816fd102078bfa7d5530cd2f0b0e804f7e5 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Fri, 20 Feb 2026 13:02:57 +0100 Subject: [PATCH 02/14] Add support for x-timed-grid --- .../experimental/timedgrid/FakeTimer.kt | 62 + .../timedgrid/TimedGridHelpers.kt | 202 + .../experimental/timedgrid/TimedGridTest.kt | 673 +++ .../activities/FormFillingActivity.java | 18 +- .../TimedGridSummaryAnswerCreator.kt | 89 + .../experimental/timedgrid/TimedGridWidget.kt | 319 ++ .../formentry/FormEntryMenuProvider.kt | 7 +- .../collect/android/formentry/ODKView.java | 20 + .../collect/android/utilities/Appearances.kt | 3 + .../android/widgets/WidgetFactory.java | 3 + .../formentry/FormEntryMenuProviderTest.kt | 2 +- experimental/build.gradle.kts | 12 + .../experimental/timedgrid/AssessmentType.kt | 13 + .../timedgrid/CommonTimedGridRenderer.kt | 157 + .../experimental/timedgrid/FinishType.kt | 17 + .../timedgrid/NavigationAwareWidget.kt | 15 + .../OngoingAssessmentWarningDialogFragment.kt | 18 + .../timedgrid/PausableCountDownTimer.kt | 70 + .../timedgrid/TimedGridRenderer.kt | 31 + .../experimental/timedgrid/TimedGridState.kt | 6 + .../timedgrid/TimedGridSummary.kt | 46 + .../timedgrid/TimedGridViewModel.kt | 29 + .../timedgrid/TimedGridWidgetConfiguration.kt | 193 + .../timedgrid/TimedGridWidgetLayout.kt | 436 ++ .../collect/experimental/timedgrid/Timer.kt | 24 + .../color/timed_grid_button_tint_selector.xml | 36 + .../res/drawable/row_number_background.xml | 6 + .../src/main/res/layout/timed_grid.xml | 72 + .../res/layout/timed_grid_item_button.xml | 18 + .../main/res/layout/timed_grid_item_row.xml | 32 + experimental/src/main/res/values/colors.xml | 8 + experimental/src/main/res/values/dimens.xml | 5 + experimental/src/main/res/values/strings.xml | 40 + experimental/src/main/res/values/styles.xml | 18 + .../main/resources/forms/timed-grid-form.xml | 4401 +++++++++++++++++ 35 files changed, 7098 insertions(+), 3 deletions(-) create mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt create mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt create mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt create mode 100644 collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt create mode 100644 collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt create mode 100644 experimental/src/main/res/color/timed_grid_button_tint_selector.xml create mode 100644 experimental/src/main/res/drawable/row_number_background.xml create mode 100644 experimental/src/main/res/layout/timed_grid.xml create mode 100644 experimental/src/main/res/layout/timed_grid_item_button.xml create mode 100644 experimental/src/main/res/layout/timed_grid_item_row.xml create mode 100644 experimental/src/main/res/values/colors.xml create mode 100644 experimental/src/main/res/values/dimens.xml create mode 100644 experimental/src/main/res/values/strings.xml create mode 100644 experimental/src/main/res/values/styles.xml create mode 100644 test-forms/src/main/resources/forms/timed-grid-form.xml diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt new file mode 100644 index 00000000000..e7a99ad0e77 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt @@ -0,0 +1,62 @@ +package org.odk.collect.android.feature.experimental.timedgrid + +import android.os.Handler +import android.os.Looper +import org.odk.collect.experimental.timedgrid.Timer + +class FakeTimer : Timer { + private val handler = Handler(Looper.getMainLooper()) + private var millisRemaining: Long = 0 + private var isPaused = false + + private lateinit var onTick: (millisUntilFinished: Long) -> Unit + private lateinit var onFinish: () -> Unit + + override fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { + this.onTick = onTick + this.onFinish = onFinish + } + + override fun setUpDuration(millisRemaining: Long) { + this.millisRemaining = millisRemaining + } + + override fun start(): Timer { + if (!isPaused) { + isPaused = false + return FakeTimer().also { + handler.post { + onTick(millisRemaining) + } + } + } + return this + } + + override fun pause() { + isPaused = true + } + + override fun cancel() { + isPaused = false + } + + override fun getMillisRemaining(): Long { + return millisRemaining + } + + fun wait(seconds: Int) { + if (isPaused) return + + repeat(seconds) { + millisRemaining -= 1000 + if (millisRemaining <= 0) { + millisRemaining = 0 + handler.post { onFinish() } + return + } else { + handler.post { onTick(millisRemaining) } + } + } + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt new file mode 100644 index 00000000000..6dceaadece6 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt @@ -0,0 +1,202 @@ +package org.odk.collect.android.feature.experimental.timedgrid + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.Matcher +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.odk.collect.android.R +import org.odk.collect.android.support.matchers.CustomMatchers.withIndex +import org.odk.collect.android.support.pages.FormEntryPage +import java.util.function.Consumer + +object TimedGridHelpers { + fun FormEntryPage.clickStartTestButton(): FormEntryPage { + onView(withId(org.odk.collect.experimental.R.id.button_start)).perform(scrollTo(), click()) + return this + } + + fun FormEntryPage.clickPauseTestButton(): FormEntryPage { + onView(withId(org.odk.collect.experimental.R.id.button_timer)).perform(scrollTo(), click()) + return this + } + + fun FormEntryPage.selectTestAnswers(items: List): FormEntryPage { + items.forEach(Consumer { item: String? -> + onView(withIndex(ViewMatchers.withText(item), 0)).perform(click()) + }) + return this + } + + fun FormEntryPage.clickUntilEarlyFinish(): FormEntryPage { + var found = false + while (!found) { + try { + // Try to click Early Finish directly by ID + onView(withId(org.odk.collect.experimental.R.id.button_complete)).perform(scrollTo(), click()) + found = true + } catch (e: Exception) { + // If not clickable yet, go to next page + onView(withId(org.odk.collect.experimental.R.id.button_next)).perform(scrollTo(), click()) + } + } + return this + } + + fun FormEntryPage.clickForwardButtonWithError(): FormEntryPage { + closeSoftKeyboard() + onView(ViewMatchers.withText(getTranslatedString(org.odk.collect.strings.R.string.form_forward))).perform( + click() + ) + assertNavigationBlockedWarning() + return this + } + + fun FormEntryPage.clickGoToArrowWithError(): FormEntryPage { + onView(withId(R.id.menu_goto)).perform(click()) + assertNavigationBlockedWarning() + return this + } + + fun FormEntryPage.clickProjectSettingsWithError(): FormEntryPage { + onView(ViewMatchers.withText(getTranslatedString(org.odk.collect.strings.R.string.project_settings))).perform( + click() + ) + assertNavigationBlockedWarning() + return this + } + + fun FormEntryPage.assertEarlyFinishDialogAndConfirm(): FormEntryPage { + assertText("Early Finish") + assertText("Do you want to end the test now?") + clickOnText("End Test") + return this + } + + fun FormEntryPage.assertLastAttemptedItemDialogAndConfirm(item: String): FormEntryPage { + clickOnText(item) + assertText("Last Attempted Item") + assertText("Do you want to confirm the last attempted item?") + clickOnText("Yes") + return this + } + + fun FormEntryPage.assertTestEndedEarlyDialogAndConfirm(endAfter: Int): FormEntryPage { + assertText("Test Ended Early") + assertText("You have reached the limit of $endAfter consecutive wrong answers.") + clickOnText("OK") + return this + } + + fun FormEntryPage.assertConsecutiveMistakesDialogAndContinue(endAfter: Int): FormEntryPage { + assertText("Consecutive Mistakes") + assertText("You have made $endAfter consecutive mistakes. Do you want to end the test or continue?") + clickOnText("Continue") + return this + } + + fun FormEntryPage.clickFinishTestButton(): FormEntryPage { + onView(withId(org.odk.collect.experimental.R.id.button_finish)).perform(scrollTo(), click()) + return this + } + + private fun FormEntryPage.assertNavigationBlockedWarning() { + assertText("Assessment…") + assertText("You must finish assessment before leaving this screen.") + clickOnText("OK") + } + + /** + * Generate expected correct items string dynamically. + */ + fun expectedCorrectItems( + allItems: List, + tapped: Set, + lastAttemptedItem: String? = null + ): String { + val cutoffIndex = if (lastAttemptedItem != null) { + allItems.indexOf(lastAttemptedItem).takeIf { it != -1 } ?: allItems.lastIndex + } else { + allItems.lastIndex + } + + val subset = allItems.subList(0, cutoffIndex + 1).toMutableList() + + // Remove only the first occurrence of each tapped item + tapped.forEach { tappedItem -> + val idx = subset.indexOf(tappedItem) + if (idx != -1) { + subset.removeAt(idx) + } + } + + return subset.joinToString(", ") + } + + /** + * Returns the list of items not attempted/answered, + * i.e. everything after the lastAttemptedItem in the full list. + */ + fun notAttemptedItems(allItems: List, lastAttemptedItem: String): List { + val index = allItems.indexOf(lastAttemptedItem) + return if (index != -1 && index < allItems.size - 1) { + allItems.subList(index + 1, allItems.size) + } else { + emptyList() + } + } + + fun extractTimeLeft(text: String): Int { + // Example: "Time Left: 58" -> 58 + return text.substringAfter("Time Left: ").trim().toInt() + } + + fun getTextFromView(resId: Int): String { + var text = "" + onView(withId(resId)).perform(object : ViewAction { + override fun getConstraints(): Matcher { + return Matchers.instanceOf(TextView::class.java) + } + + override fun getDescription() = "Get text from a TextView" + + override fun perform(uiController: UiController?, view: View?) { + val tv = view as TextView + text = tv.text.toString() + } + }) + return text + } + + fun assertVisibleRows(count: Int) { + onView(withId(org.odk.collect.experimental.R.id.container_rows)) + .check(assertVisibleChildCount(count)) + } + + private fun assertVisibleChildCount(expected: Int): ViewAssertion { + return ViewAssertion { view, noViewFoundException -> + if (noViewFoundException != null) throw noViewFoundException + if (view !is ViewGroup) throw AssertionError("View is not a ViewGroup") + + val visibleCount = (0 until view.childCount) + .map { view.getChildAt(it) } + .count { it.visibility == View.VISIBLE } + + assertThat( + "Expected $expected visible children but was $visibleCount", + visibleCount, + equalTo(expected) + ) + } + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt new file mode 100644 index 00000000000..1c4900cfb13 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt @@ -0,0 +1,673 @@ +package org.odk.collect.android.feature.experimental.timedgrid + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertConsecutiveMistakesDialogAndContinue +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertEarlyFinishDialogAndConfirm +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertLastAttemptedItemDialogAndConfirm +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertTestEndedEarlyDialogAndConfirm +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickFinishTestButton +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickForwardButtonWithError +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickGoToArrowWithError +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickPauseTestButton +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickProjectSettingsWithError +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickStartTestButton +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickUntilEarlyFinish +import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.selectTestAnswers +import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.experimental.timedgrid.TimerProvider +import kotlin.math.roundToInt + +@RunWith(AndroidJUnit4::class) +class TimedGridTest { + + private val rule = CollectTestRule() + + @get:Rule + var copyFormChain: RuleChain = TestRuleChain.chain() + .around(rule) + + private val timer = FakeTimer() + + @Before + fun setup() { + TimerProvider.factory = { timer } + } + + // --- Default Letters --- + @Test + fun testLetters_oneWrongAnswer_summaryCorrect() { + val tapped = listOf("E") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Type X (Letters alias) --- + @Test + fun testTypeX_oneWrongAnswer_summaryCorrect() { + val tapped = listOf("a") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Type X Letters Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Words --- + @Test + fun testWords_threeWrongAnswers_summaryCorrect() { + val tapped = listOf("tob", "lig", "pum") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allWords, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Words Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allWords.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allWords.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Reading --- + @Test + fun testReading_threeWrongAnswers_summaryCorrect() { + val tapped = listOf("Lorem", "ipsum", "dolor") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allReading, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Reading Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allReading.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allReading.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "20") + } + + // --- Numbers --- + @Test + fun testNumbers_threeWrongAnswers_summaryCorrect() { + val tapped = listOf("1", "2", "3") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allNumbers, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Numbers Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allNumbers.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allNumbers.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "false") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Arithmetic --- + @Test + fun testArithmetic_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("1 + 2 = 3", "5 + 5 = 10") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allArithmetic, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Arithmetic Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allArithmetic.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allArithmetic.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "True") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Finish=2 --- + @Test + fun testLettersFinish2_oneWrongAnswer_summaryCorrect() { + val tapped = listOf("E") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Finish 2") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .assertEarlyFinishDialogAndConfirm() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Finish=1 --- + @Test + fun testLettersFinish1_twoWrongAnswers_lastAttemptedItem_summaryCorrect() { + val tapped = listOf("a", "w") + val lastAttemptedItem = "I" + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet(), lastAttemptedItem) + val expectedNotAttempted = TimedGridHelpers.notAttemptedItems(allLetters, lastAttemptedItem) + val totalAttempted = allLetters.size - expectedNotAttempted.size + val correctItems = totalAttempted - tapped.size + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Finish 1") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .assertEarlyFinishDialogAndConfirm() + .assertLastAttemptedItemDialogAndConfirm(lastAttemptedItem) + .clickFinishTestButton() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", totalAttempted.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", correctItems.toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", expectedNotAttempted.joinToString(", ")) + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters No Pause --- + @Test + fun testLettersNoPause_oneWrongAnswer_summaryCorrect() { + val tapped = listOf("a") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters No Pause") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .also { + // Capture time before + val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) + + it.clickOnId(org.odk.collect.experimental.R.id.button_timer) + timer.wait(2) + + // Capture time after + val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) + + // Assert that the time has decreased + assert(timeAfter < timeBefore) { + "Timer did not continue after clicking Timer button. Before=$timeBefore After=$timeAfter" + } + } + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "53") // 60 - 5 - 2 + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Strict True + Duration=10 --- + @Test + fun testLettersStrictTrueDuration10_oneWrongAnswer_lastAttemptedItem_summaryCorrect() { + val tapped = listOf("a") + val lastAttemptedItem = "w" + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet(), lastAttemptedItem) + val expectedNotAttempted = TimedGridHelpers.notAttemptedItems(allLetters, lastAttemptedItem) + val totalAttempted = allLetters.size - expectedNotAttempted.size + val correctItems = totalAttempted - tapped.size + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Strict True Duration 10") + .clickStartTestButton() + .also { timer.wait(2) } + .selectTestAnswers(tapped) + .also { timer.wait(10) } + .assertLastAttemptedItemDialogAndConfirm(lastAttemptedItem) + .clickFinishTestButton() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "0") + .assertAnswer("Total number of items attempted", totalAttempted.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", correctItems.toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", expectedNotAttempted.joinToString(", ")) + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Strict False + Duration=10 --- + @Test + fun testLettersStrictFalseDuration10_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("a", "w") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Strict False Duration 10") + .clickStartTestButton() + .also { timer.wait(2) } + .selectTestAnswers(tapped) + .also { timer.wait(10) } + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "0") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Strict True + EndAfter=2 --- + @Test + fun testLettersStrictTrueEndAfter2_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Strict True End After 2") + .clickStartTestButton() + .selectTestAnswers(tapped) + .assertTestEndedEarlyDialogAndConfirm(2) + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", ((timer.getMillisRemaining() / 1000.0).roundToInt()).toString()) + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Strict False + EndAfter=2 --- + @Test + fun testLettersStrictFalseEndAfter2_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Strict False End After 2") + .clickStartTestButton() + .selectTestAnswers(tapped) + .assertConsecutiveMistakesDialogAndContinue(2) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", ((timer.getMillisRemaining() / 1000.0).roundToInt()).toString()) + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters PageRows=2 --- + @Test + fun testLettersPageRows2_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Page Rows 2") + .clickStartTestButton() + .also { TimedGridHelpers.assertVisibleRows(2) } + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Grid + Summary --- + @Test + fun testLettersGridAndSummary_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnGroup("Letters Grid And Summary:") + .clickOnQuestion("Letters Grid And Summary") + // Assert summary fields are visible immediately + .assertTexts("Amount of time remaining in seconds", "Total number of items attempted", "Number of incorrect items", + "Number of correct items", "Whether the firstline was all incorrect", "The list of correct items", + "The list of items not attempted/answered", "The total number of punctuation marks") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Grid + Summary (Relevant / Group 2) --- + @Test + fun testLettersGridAndSummary2_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnGroup("Letters Grid And Summary + Relevant:") + .clickOnQuestion("Letters Grid And Summary Relevant") + // Assert summary fields are not visible yet + .assertTextsDoNotExist("Amount of time remaining in seconds", "Total number of items attempted", "Number of incorrect items", + "Number of correct items", "Whether the firstline was all incorrect", "The list of correct items", + "The list of items not attempted/answered", "The total number of punctuation marks") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .assertAnswer("Amount of time remaining in seconds", "55") + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .assertAnswer("Whether the firstline was all incorrect", "False") + .assertAnswer("The list of correct items", expectedCorrect) + .assertAnswer("The list of items not attempted/answered", "") + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Letters Grid No Group --- + @Test + fun testLettersNoGroup_twoWrongAnswers_summaryCorrect() { + val tapped = listOf("L", "i") + val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters No Group") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .clickUntilEarlyFinish() + .clickForwardButton() + .assertAnswer("Amount of time remaining in seconds", "55") + .clickForwardButton() + .assertAnswer("Total number of items attempted", allLetters.size.toString()) + .clickForwardButton() + .assertAnswer("Number of incorrect items", tapped.size.toString()) + .clickForwardButton() + .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) + .clickForwardButton() + .assertAnswer("Whether the firstline was all incorrect", "False") + .clickForwardButton() + .assertAnswer("The number of sentence end marks (e.g. periods) passed, as indicated by the last attempted item when using the oral reading test type", "0") + .clickForwardButton() + .assertAnswer("The list of correct items", expectedCorrect) + .clickForwardButton() + .assertAnswer("The list of items not attempted/answered", "") + .clickForwardButton() + .assertAnswer("The total number of punctuation marks", "0") + } + + // --- Check If Timer Works When Rotating The Device --- + @Test + fun checkTimerWhenRotating_timerContinuesRunning() { + val tapped = listOf("a", "w") + + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Test") + .clickStartTestButton() + .also { timer.wait(5) } + .selectTestAnswers(tapped) + .also { + // Capture timer before rotation + val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) + + // Rotate device + it.rotateToLandscape(it) + timer.wait(5) + + // Capture timer after rotation + val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) + + // Assert timer is still running + assert(timeAfter < timeBefore) { + "Timer did not continue after rotation. Before=$timeBefore After=$timeAfter" + } + } + } + + @Test + fun blockNavigationWhileTestRunning() { + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Letters Test") + .clickStartTestButton() + .clickForwardButtonWithError() + .clickGoToArrowWithError() + .clickOptionsIcon() + .clickProjectSettingsWithError() + .clickPauseTestButton() + .clickForwardButtonWithError() + .clickGoToArrowWithError() + .clickOptionsIcon() + .clickProjectSettingsWithError() + } + + @Test + fun testTranslationsAreApplied() { + rule.startAtMainMenu() + .copyForm("timed-grid-form.xml") + .startBlankForm("timed-grid-form") + .clickGoToArrow() + .clickOnQuestion("Words Test") + // Assert original (English) labels + .assertTexts(*allWords.take(3).toTypedArray()) + // Change language + .clickOptionsIcon() + .clickOnString(org.odk.collect.strings.R.string.change_language) + .clickOnText("Polish (pl)") + // Assert translated labels + .assertTexts(*allWordsPl.take(3).toTypedArray()) + } + + companion object { + private val allLetters = listOf( + "L", "i", "h", "R", "S", "y", "E", "O", "n", "T", + "i", "e", "T", "D", "A", "t", "a", "d", "e", "w", + "h", "O", "e", "m", "U", "r", "L", "G", "R", "u", + "G", "R", "B", "E", "i", "f", "m", "t", "s", "r", + "S", "T", "C", "N", "p", "A", "F", "c", "G", "E", + "y", "Q", "A", "M", "C", "O", "t", "n", "P", "s", + "e", "A", "b", "s", "O", "F", "h", "u", "A", "t", + "R", "z", "H", "e", "S", "i", "g", "m", "i", "L", + "o", "I", "N", "O", "e", "L", "E", "r", "p", "X", + "H", "A", "c", "D", "d", "t", "O", "j", "e", "n" + ) + + private val allWords = listOf( + "tob", "lig", "pum", "inbok", "maton", + "gatch", "tup", "noom", "sen", "timming", + "beeth", "mun", "ellus", "fot", "widge", + "han", "pite", "dazz", "unstade", "rike", + "fipper", "chack", "gub", "weem", "foin", + "ithan", "feth", "tade", "anth", "bom", + "ruck", "rax", "jad", "foob", "bapent", + "sull", "lotch", "snim", "queet", "reb", + "lunkest", "vown", "coll", "kittle", "moy", + "div", "trinless", "pran", "nauk", "otta" + ) + + private val allWordsPl = listOf( + "tob (pl)", "lig (pl)", "pum (pl)", "inbok (pl)", "maton (pl)", + "gatch (pl)", "tup (pl)", "noom (pl)", "sen (pl)", "timming (pl)", + "beeth (pl)", "mun (pl)", "ellus (pl)", "fot (pl)", "widge (pl)", + "han (pl)", "pite (pl)", "dazz (pl)", "unstade (pl)", "rike (pl)", + "fipper (pl)", "chack (pl)", "gub (pl)", "weem (pl)", "foin (pl)", + "ithan (pl)", "feth (pl)", "tade (pl)", "anth (pl)", "bom (pl)", + "ruck (pl)", "rax (pl)", "jad (pl)", "foob (pl)", "bapent (pl)", + "sull (pl)", "lotch (pl)", "snim (pl)", "queet (pl)", "reb (pl)", + "lunkest (pl)", "vown (pl)", "coll (pl)", "kittle (pl)", "moy (pl)", + "div (pl)", "trinless (pl)", "pran (pl)", "nauk (pl)", "otta (pl)" + ) + + private val allReading = listOf( + "Lorem", "ipsum", "dolor", "sit", "amet", ",", + "consectetur", "adipiscing", "elit", ".", + "Phasellus", "vel", "tortor", "neque", ".", + "Nulla", "vestibulum", "dictum", "nibh", ",", + "eu", "vehicula", "felis", ".", "Suspendisse", + "condimentum", "turpis", "ac", "viverra", "fermentum", + ".", "Ut", "tincidunt", "metus", "a", "ante", + "rhoncus", "suscipit", ".", "Ut", "sed", "lacus", + "egestas", ",", "aliquam", "urna", "eu", "sollicitudin", + "risus", ".", "Vestibulum", "imperdiet", "bibendum", + "imperdiet", ".", "Quisque", "vitae", "felis", "tellus", + ".", "Vivamus", "sit", "amet", "consectetur", "diam", + ",", "eget", "auctor", "ligula", ".", "Phasellus", + "vestibulum", ",", "ante", "id", "pharetra", "iaculis", + ",", "mi", "nibh", "tristique", "urna", ",", "in", + "lacinia", "arcu", "risus", "eu", "urna.", ".", + "Aenean", "sollicitudin", "elementum", "erat", "vel", + "feugiat", ".", "Nullam", "venenatis", "mattis", "metus", + ",", "vel", "fringilla", "nunc", "pulvinar", "a", "." + ) + + private val allNumbers = (1..50).map { it.toString() } + + private val allArithmetic = listOf( + "1 + 2 = 3", "5 + 5 = 10", "7 + 1 = 8", "4 + 3 = 7", "6 + 0 = 6", + "5 - 2 = 3", "8 - 4 = 4", "10 - 1 = 9", "9 - 6 = 3", "7 - 7 = 0", + "2 x 3 = 6", "4 x 2 = 8", "5 x 4 = 20", "3 x 3 = 9", "1 x 8 = 8", + "10 ÷ 2 = 5", "9 ÷ 3 = 3", "8 ÷ 4 = 2", "6 ÷ 1 = 6", "12 ÷ 6 = 2" + ) + } +} 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..7b77cf8502a 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 @@ -388,7 +388,9 @@ public void allowSwiping(boolean doSwipe) { private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording()) { + if (odkView != null && odkView.isNavigationBlocked()) { + DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + } else if (audioRecorder.isRecording() && !backgroundAudioViewModel.isBackgroundRecording()) { // We want the user to stop recording before changing screens DialogFragmentUtils.showIfNotShowing(RecordingWarningDialogFragment.class, getSupportFragmentManager()); } else { @@ -496,6 +498,14 @@ public void changeLanguage() { public void save() { saveForm(false, InstancesDaoHelper.isInstanceComplete(getFormController()), null, true); } + }, + () -> { + if (odkView != null && odkView.isNavigationBlocked()) { + DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + swipeHandler.setBeenSwiped(false); + return false; + } + return true; } ); @@ -1203,6 +1213,12 @@ private void moveScreen(Direction direction) { return; } + if (odkView != null && odkView.isNavigationBlocked()) { + DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + 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/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt new file mode 100644 index 00000000000..1bff72befc2 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -0,0 +1,89 @@ +package org.odk.collect.android.experimental.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.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 +import org.odk.collect.android.activities.FormFillingActivity +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.widgets.StringWidget +import org.odk.collect.experimental.timedgrid.TimedGridSummary + +class TimedGridSummaryAnswerCreator( + val formEntryPrompt: FormEntryPrompt, + val formEntryViewModel: FormEntryViewModel, + val formFillingActivity: FormFillingActivity? +) { + companion object { + val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""timed-grid-answer\((.+),(.+)\)""") + } + + fun answerSummaryQuestions(summary: TimedGridSummary) { + val timedGridQuestionId = formEntryPrompt.index.reference.toString(false) + + forEachFormQuestionDef(formEntryViewModel.formController.getFormDef()?.children) { 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) + + formEntryViewModel.formController.saveOneScreenAnswer( + questionIndex, + answer, + false + ) + + formFillingActivity?.currentViewIfODKView?.widgets + ?.filterIsInstance() + ?.find { widget -> widget.formEntryPrompt.index == questionIndex } + ?.apply { + setDisplayValueFromModel() + widgetValueChanged() + showAnswerContainer() + } + } + } + } + } + + 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") + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt new file mode 100644 index 00000000000..c5a56a65d0a --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt @@ -0,0 +1,319 @@ +package org.odk.collect.android.experimental.timedgrid + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.fragment.app.DialogFragment +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 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.QuestionWidget +import org.odk.collect.android.widgets.items.ItemsWidgetUtils.loadItemsAndHandleErrors +import org.odk.collect.experimental.timedgrid.FinishType +import org.odk.collect.experimental.timedgrid.GridItem +import org.odk.collect.experimental.timedgrid.NavigationAwareWidget +import org.odk.collect.experimental.timedgrid.OngoingAssessmentWarningDialogFragment +import org.odk.collect.experimental.timedgrid.TimedGridState +import org.odk.collect.experimental.timedgrid.TimedGridSummary +import org.odk.collect.experimental.timedgrid.TimedGridViewModel +import org.odk.collect.experimental.timedgrid.TimedGridWidgetConfiguration +import org.odk.collect.experimental.timedgrid.Timer +import org.odk.collect.experimental.timedgrid.TimerProvider +import kotlin.jvm.java +import kotlin.time.Duration.Companion.milliseconds + +@SuppressLint("ViewConstructor") +class TimedGridWidget( + context: Context, + questionDetails: QuestionDetails, + dependencies: Dependencies, + formEntryViewModel: FormEntryViewModel +) : QuestionWidget(context, dependencies, questionDetails), NavigationAwareWidget { + private val viewModel = ViewModelProvider(context as ViewModelStoreOwner)[TimedGridViewModel::class.java] + private val timer: Timer = TimerProvider.get() + + // Parsed prompt configuration (type, duration, etc.). + private val config = TimedGridWidgetConfiguration.fromPrompt( + questionDetails.prompt + ).also { + timer.setUpDuration(it.duration.inWholeMilliseconds) + } + + private val summaryAnswerCreator = + TimedGridSummaryAnswerCreator( + formEntryPrompt, + formEntryViewModel, + context.takeIf { it is FormFillingActivity } as FormFillingActivity? + ) + + private val items = loadItemsAndHandleErrors( + this, questionDetails.prompt, formEntryViewModel + ) + + // Filtered items to display in the grid. + private val gridItems = items + .filter { it.value != config.allAnsweredCorrectly } + .map { GridItem(it.value, questionDetails.prompt.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() + + // Trigger view creation. + init { + render() + } + + override fun onCreateWidgetView( + context: Context, + prompt: FormEntryPrompt, + answerFontSize: Int + ): View { + val inflater = LayoutInflater.from(context) + val root = renderer.inflateView(inflater, this) + 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( + org.odk.collect.experimental.R.string.timed_grid_time_left, + secondsRemaining + ) + renderer.updateTimer(timeLeftText) + summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + } + + renderer.updateUIForState(state) + } ?: run { + readSavedItems() + renderer.updateUIForState(state) + } + + return root + } + + fun onTimerTick(millisUntilFinished: Long) { + val secondsRemaining = millisUntilFinished.milliseconds.inWholeSeconds + val timeLeftText = context.getString( + org.odk.collect.experimental.R.string.timed_grid_time_left, + secondsRemaining + ) + renderer.updateTimer(timeLeftText) + summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + } + + fun onTimerFinish() { + val timeLeftText = context.getString( + org.odk.collect.experimental.R.string.timed_grid_time_left, + 0 + ) + renderer.updateTimer(timeLeftText) + if (config.strict) { + completeAssessment() + } + } + + private fun saveTimerState() { + viewModel.saveTimedGridState( + formEntryPrompt.index, + TimedGridViewModel.TimedGridTimerState( + millisRemaining = timer.getMillisRemaining(), + state = state, + toggledAnswers = renderer.getToggledAnswers(), + lastAttempted = renderer.getLastSelectedLastItemValue() + ) + ) + } + + 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 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 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(org.odk.collect.experimental.R.string.early_finish_title) + .setMessage(org.odk.collect.experimental.R.string.early_finish_message) + .setPositiveButton(org.odk.collect.experimental.R.string.end_test) { _, _ -> + onConfirm() + } + .setNegativeButton(org.odk.collect.experimental.R.string.continue_test, null) + .show() + } + + private fun autoPickLastItem() { + val lastItemValue = gridItems.lastOrNull()?.value + if (lastItemValue != null) { + renderer.restoreAnswers(renderer.getToggledAnswers(), lastItemValue) + } + } + + 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 } + ) + } + } + + /** + * 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". + */ + override 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]) }) + } + + override fun clearAnswer() {} + override fun setOnLongClickListener(l: OnLongClickListener?) {} + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // Ensure state is saved when widget is removed + saveTimerState() + timer.cancel() + } + + override fun shouldBlockNavigation(): Boolean = + state == TimedGridState.IN_PROGRESS || + state == TimedGridState.PAUSED || + state == TimedGridState.COMPLETED_NO_LAST_ITEM + + override fun getWarningDialog(): Class = + OngoingAssessmentWarningDialogFragment::class.java +} 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..5eabde04b07 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 canPerformMenuItemClick: () -> 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 (!canPerformMenuItemClick()) { + 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..8e7ad706689 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 @@ -41,6 +41,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.widget.NestedScrollView; +import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; @@ -86,6 +87,7 @@ import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton; import org.odk.collect.audioclips.PlaybackFailedException; import org.odk.collect.audiorecorder.recording.AudioRecorder; +import org.odk.collect.experimental.timedgrid.NavigationAwareWidget; import org.odk.collect.permissions.PermissionListener; import org.odk.collect.permissions.PermissionsProvider; import org.odk.collect.settings.SettingsProvider; @@ -97,6 +99,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import javax.inject.Inject; @@ -804,4 +807,21 @@ private void updateQuestions(FormEntryPrompt[] prompts) { this.questions.add(new ImmutableDisplayableQuestion(questionAfterSave)); } } + + public boolean isNavigationBlocked() { + return getFirstNavigationBlockingWidget().isPresent(); + } + + public Optional> getFirstNavigationBlockedWarningDialog() { + return getFirstNavigationBlockingWidget().map(NavigationAwareWidget::getWarningDialog); + } + + private Optional getFirstNavigationBlockingWidget() { + return widgets + .stream() + .filter(widget -> widget instanceof NavigationAwareWidget) + .map(widget -> (NavigationAwareWidget) widget) + .filter(NavigationAwareWidget::shouldBlockNavigation) + .findFirst(); + } } 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..384730e9c29 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 @@ -92,6 +92,9 @@ object Appearances { const val COUNTER = "counter" const val MULTILINE = "multiline" + // Experimental + const val TIMED_GRID = "timed-grid" + // Get appearance hint and clean it up so it is lower case, without the search function and never null. @JvmStatic fun getSanitizedAppearanceHint(fep: FormEntryPrompt): String { 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..61e70f5d196 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 @@ -26,6 +26,7 @@ import org.javarosa.core.model.Constants; import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.experimental.timedgrid.TimedGridWidget; import org.odk.collect.android.formentry.FormEntryViewModel; import org.odk.collect.android.formentry.PrinterWidgetViewModel; import org.odk.collect.android.formentry.questions.QuestionDetails; @@ -265,6 +266,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.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/experimental/build.gradle.kts b/experimental/build.gradle.kts index 52758a92a79..7cb41774a97 100644 --- a/experimental/build.gradle.kts +++ b/experimental/build.gradle.kts @@ -39,4 +39,16 @@ android { } dependencies { + 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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt new file mode 100644 index 00000000000..e086de25f74 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt @@ -0,0 +1,13 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt new file mode 100644 index 00000000000..bd55d46873d --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt @@ -0,0 +1,157 @@ +package org.odk.collect.experimental.timedgrid + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt new file mode 100644 index 00000000000..eaf57b2fdda --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt @@ -0,0 +1,17 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt new file mode 100644 index 00000000000..4c7cfd89298 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt @@ -0,0 +1,15 @@ +package org.odk.collect.experimental.timedgrid + +import androidx.fragment.app.DialogFragment + +interface NavigationAwareWidget { + /** + * Whenever the navigation should be stopped. + */ + fun shouldBlockNavigation(): Boolean + + /** + * If navigation should be stopped (see #shouldBlockNavigation) this returns DialogFragment to present to user. + */ + fun getWarningDialog(): Class +} diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt new file mode 100644 index 00000000000..afc7c6bf03e --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt @@ -0,0 +1,18 @@ +package org.odk.collect.experimental.timedgrid + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.experimental.R + +class OngoingAssessmentWarningDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.assessment) + .setMessage(R.string.assessment_warning) + .setPositiveButton(org.odk.collect.strings.R.string.ok, null) + .create() + } +} diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt new file mode 100644 index 00000000000..4be8cf370f7 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt @@ -0,0 +1,70 @@ +package org.odk.collect.experimental.timedgrid + +import android.os.CountDownTimer + +class PausableCountDownTimer : Timer { + 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 + + override fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { + this.onTick = onTick + this.onFinish = onFinish + } + + override fun setUpDuration(millisRemaining: Long) { + this.millisRemaining = millisRemaining + } + + /** + * Starts or resumes the countdown. + * @return This PausableCountDownTimer. + */ + @Synchronized + override fun start(): Timer { + 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 + override fun pause() { + if (!isPaused) { + timer?.cancel() + timer = null + } + isPaused = true + } + + /** + * Cancels the countdown and resets the timer. + */ + @Synchronized + override fun cancel() { + timer?.cancel() + timer = null + isPaused = true + } + + override fun getMillisRemaining(): Long { + return millisRemaining + } +} diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt new file mode 100644 index 00000000000..35b4ddf5432 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt @@ -0,0 +1,31 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt new file mode 100644 index 00000000000..83187b1fb09 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt @@ -0,0 +1,6 @@ +package org.odk.collect.experimental.timedgrid + +// Lifecycle states for the timed grid widget. +enum class TimedGridState { + NEW, IN_PROGRESS, PAUSED, COMPLETED_NO_LAST_ITEM, COMPLETED +} diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt new file mode 100644 index 00000000000..046667a6668 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt @@ -0,0 +1,46 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt new file mode 100644 index 00000000000..6c642a10908 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt @@ -0,0 +1,29 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt new file mode 100644 index 00000000000..e0fa0db4719 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt @@ -0,0 +1,193 @@ +package org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt new file mode 100644 index 00000000000..84b9b214870 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt @@ -0,0 +1,436 @@ +package org.odk.collect.experimental.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.experimental.R +import org.odk.collect.experimental.databinding.TimedGridItemButtonBinding +import org.odk.collect.experimental.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/experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt new file mode 100644 index 00000000000..6df440c1c42 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt @@ -0,0 +1,24 @@ +package org.odk.collect.experimental.timedgrid + +interface Timer { + fun setUpListeners( + onTick: (millisUntilFinished: Long) -> Unit, + onFinish: () -> Unit + ) + + fun setUpDuration(millisRemaining: Long) + + fun start(): Timer + + fun pause() + + fun cancel() + + fun getMillisRemaining(): Long +} + +object TimerProvider { + var factory: () -> Timer = { PausableCountDownTimer() } + + fun get(): Timer = factory() +} diff --git a/experimental/src/main/res/color/timed_grid_button_tint_selector.xml b/experimental/src/main/res/color/timed_grid_button_tint_selector.xml new file mode 100644 index 00000000000..513f7da9618 --- /dev/null +++ b/experimental/src/main/res/color/timed_grid_button_tint_selector.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/experimental/src/main/res/drawable/row_number_background.xml b/experimental/src/main/res/drawable/row_number_background.xml new file mode 100644 index 00000000000..d72631ad018 --- /dev/null +++ b/experimental/src/main/res/drawable/row_number_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/experimental/src/main/res/layout/timed_grid.xml b/experimental/src/main/res/layout/timed_grid.xml new file mode 100644 index 00000000000..64eae0fd081 --- /dev/null +++ b/experimental/src/main/res/layout/timed_grid.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/experimental/src/main/res/layout/timed_grid_item_button.xml b/experimental/src/main/res/layout/timed_grid_item_button.xml new file mode 100644 index 00000000000..5dcc92aac2f --- /dev/null +++ b/experimental/src/main/res/layout/timed_grid_item_button.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/experimental/src/main/res/layout/timed_grid_item_row.xml b/experimental/src/main/res/layout/timed_grid_item_row.xml new file mode 100644 index 00000000000..7a30da8f207 --- /dev/null +++ b/experimental/src/main/res/layout/timed_grid_item_row.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/experimental/src/main/res/values/colors.xml b/experimental/src/main/res/values/colors.xml new file mode 100644 index 00000000000..2dcbc3a25f5 --- /dev/null +++ b/experimental/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #2196F3 + #FF9800 + #E0E0E0 + #FFEB3B + #4CAF50 + \ No newline at end of file diff --git a/experimental/src/main/res/values/dimens.xml b/experimental/src/main/res/values/dimens.xml new file mode 100644 index 00000000000..28f5abbd228 --- /dev/null +++ b/experimental/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 4dp + 8dp + \ No newline at end of file diff --git a/experimental/src/main/res/values/strings.xml b/experimental/src/main/res/values/strings.xml new file mode 100644 index 00000000000..694b50364e1 --- /dev/null +++ b/experimental/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/experimental/src/main/res/values/styles.xml b/experimental/src/main/res/values/styles.xml new file mode 100644 index 00000000000..b9badf3b8a9 --- /dev/null +++ b/experimental/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/test-forms/src/main/resources/forms/timed-grid-form.xml b/test-forms/src/main/resources/forms/timed-grid-form.xml new file mode 100644 index 00000000000..b9424b5cfc5 --- /dev/null +++ b/test-forms/src/main/resources/forms/timed-grid-form.xml @@ -0,0 +1,4401 @@ + + + + timed-grid-form + + + + + L + + + i + + + h + + + R + + + S + + + y + + + E + + + O + + + n + + + T + + + i + + + e + + + T + + + D + + + A + + + t + + + a + + + d + + + e + + + w + + + h + + + O + + + e + + + m + + + U + + + r + + + L + + + G + + + R + + + u + + + G + + + R + + + B + + + E + + + i + + + f + + + m + + + t + + + s + + + r + + + S + + + T + + + C + + + N + + + p + + + A + + + F + + + c + + + G + + + E + + + y + + + Q + + + A + + + M + + + C + + + O + + + t + + + n + + + P + + + s + + + e + + + A + + + b + + + s + + + O + + + F + + + h + + + u + + + A + + + t + + + R + + + z + + + H + + + e + + + S + + + i + + + g + + + m + + + i + + + L + + + o + + + I + + + N + + + O + + + e + + + L + + + E + + + r + + + p + + + X + + + H + + + A + + + c + + + D + + + d + + + t + + + O + + + j + + + e + + + n + + + All answered + + + tob + + + lig + + + pum + + + inbok + + + maton + + + gatch + + + tup + + + noom + + + sen + + + timming + + + beeth + + + mun + + + ellus + + + fot + + + widge + + + han + + + pite + + + dazz + + + unstade + + + rike + + + fipper + + + chack + + + gub + + + weem + + + foin + + + ithan + + + feth + + + tade + + + anth + + + bom + + + ruck + + + rax + + + jad + + + foob + + + bapent + + + sull + + + lotch + + + snim + + + queet + + + reb + + + lunkest + + + vown + + + coll + + + kittle + + + moy + + + div + + + trinless + + + pran + + + nauk + + + otta + + + All answered + + + Lorem + + + ipsum + + + dolor + + + sit + + + amet + + + , + + + consectetur + + + adipiscing + + + elit + + + . + + + Phasellus + + + vel + + + tortor + + + neque + + + . + + + Nulla + + + vestibulum + + + dictum + + + nibh + + + , + + + eu + + + vehicula + + + felis + + + . + + + Suspendisse + + + condimentum + + + turpis + + + ac + + + viverra + + + fermentum + + + . + + + Ut + + + tincidunt + + + metus + + + a + + + ante + + + rhoncus + + + suscipit + + + . + + + Ut + + + sed + + + lacus + + + egestas + + + , + + + aliquam + + + urna + + + eu + + + sollicitudin + + + risus + + + . + + + Vestibulum + + + imperdiet + + + bibendum + + + imperdiet + + + . + + + Quisque + + + vitae + + + felis + + + tellus + + + . + + + Vivamus + + + sit + + + amet + + + consectetur + + + diam + + + , + + + eget + + + auctor + + + ligula + + + . + + + Phasellus + + + vestibulum + + + , + + + ante + + + id + + + pharetra + + + iaculis + + + , + + + mi + + + nibh + + + tristique + + + urna + + + , + + + in + + + lacinia + + + arcu + + + risus + + + eu + + + urna. + + + . + + + Aenean + + + sollicitudin + + + elementum + + + erat + + + vel + + + feugiat + + + . + + + Nullam + + + venenatis + + + mattis + + + metus + + + , + + + vel + + + fringilla + + + nunc + + + pulvinar + + + a + + + . + + + All answered + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + 10 + + + 11 + + + 12 + + + 13 + + + 14 + + + 15 + + + 16 + + + 17 + + + 18 + + + 19 + + + 20 + + + 21 + + + 22 + + + 23 + + + 24 + + + 25 + + + 26 + + + 27 + + + 28 + + + 29 + + + 30 + + + 31 + + + 32 + + + 33 + + + 34 + + + 35 + + + 36 + + + 37 + + + 38 + + + 39 + + + 40 + + + 41 + + + 42 + + + 43 + + + 44 + + + 45 + + + 46 + + + 47 + + + 48 + + + 49 + + + 50 + + + All answered + + + 1 + 2 = 3 + + + 5 + 5 = 10 + + + 7 + 1 = 8 + + + 4 + 3 = 7 + + + 6 + 0 = 6 + + + 5 - 2 = 3 + + + 8 - 4 = 4 + + + 10 - 1 = 9 + + + 9 - 6 = 3 + + + 7 - 7 = 0 + + + 2 x 3 = 6 + + + 4 x 2 = 8 + + + 5 x 4 = 20 + + + 3 x 3 = 9 + + + 1 x 8 = 8 + + + 10 ÷ 2 = 5 + + + 9 ÷ 3 = 3 + + + 8 ÷ 4 = 2 + + + 6 ÷ 1 = 6 + + + 12 ÷ 6 = 2 + + + All answered + + + + + tob (pl) + + + lig (pl) + + + pum (pl) + + + inbok (pl) + + + maton (pl) + + + gatch (pl) + + + tup (pl) + + + noom (pl) + + + sen (pl) + + + timming (pl) + + + beeth (pl) + + + mun (pl) + + + ellus (pl) + + + fot (pl) + + + widge (pl) + + + han (pl) + + + pite (pl) + + + dazz (pl) + + + unstade (pl) + + + rike (pl) + + + fipper (pl) + + + chack (pl) + + + gub (pl) + + + weem (pl) + + + foin (pl) + + + ithan (pl) + + + feth (pl) + + + tade (pl) + + + anth (pl) + + + bom (pl) + + + ruck (pl) + + + rax (pl) + + + jad (pl) + + + foob (pl) + + + bapent (pl) + + + sull (pl) + + + lotch (pl) + + + snim (pl) + + + queet (pl) + + + reb (pl) + + + lunkest (pl) + + + vown (pl) + + + coll (pl) + + + kittle (pl) + + + moy (pl) + + + div (pl) + + + trinless (pl) + + + pran (pl) + + + nauk (pl) + + + otta (pl) + + + Wszystkie odpowiedzi poprawne + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + letters-0 + 1 + + + letters-1 + 2 + + + letters-2 + 3 + + + letters-3 + 4 + + + letters-4 + 5 + + + letters-5 + 6 + + + letters-6 + 7 + + + letters-7 + 8 + + + letters-8 + 9 + + + letters-9 + 10 + + + letters-10 + 11 + + + letters-11 + 12 + + + letters-12 + 13 + + + letters-13 + 14 + + + letters-14 + 15 + + + letters-15 + 16 + + + letters-16 + 17 + + + letters-17 + 18 + + + letters-18 + 19 + + + letters-19 + 20 + + + letters-20 + 21 + + + letters-21 + 22 + + + letters-22 + 23 + + + letters-23 + 24 + + + letters-24 + 25 + + + letters-25 + 26 + + + letters-26 + 27 + + + letters-27 + 28 + + + letters-28 + 29 + + + letters-29 + 30 + + + letters-30 + 31 + + + letters-31 + 32 + + + letters-32 + 33 + + + letters-33 + 34 + + + letters-34 + 35 + + + letters-35 + 36 + + + letters-36 + 37 + + + letters-37 + 38 + + + letters-38 + 39 + + + letters-39 + 40 + + + letters-40 + 41 + + + letters-41 + 42 + + + letters-42 + 43 + + + letters-43 + 44 + + + letters-44 + 45 + + + letters-45 + 46 + + + letters-46 + 47 + + + letters-47 + 48 + + + letters-48 + 49 + + + letters-49 + 50 + + + letters-50 + 51 + + + letters-51 + 52 + + + letters-52 + 53 + + + letters-53 + 54 + + + letters-54 + 55 + + + letters-55 + 56 + + + letters-56 + 57 + + + letters-57 + 58 + + + letters-58 + 59 + + + letters-59 + 60 + + + letters-60 + 61 + + + letters-61 + 62 + + + letters-62 + 63 + + + letters-63 + 64 + + + letters-64 + 65 + + + letters-65 + 66 + + + letters-66 + 67 + + + letters-67 + 68 + + + letters-68 + 69 + + + letters-69 + 70 + + + letters-70 + 71 + + + letters-71 + 72 + + + letters-72 + 73 + + + letters-73 + 74 + + + letters-74 + 75 + + + letters-75 + 76 + + + letters-76 + 77 + + + letters-77 + 78 + + + letters-78 + 79 + + + letters-79 + 80 + + + letters-80 + 81 + + + letters-81 + 82 + + + letters-82 + 83 + + + letters-83 + 84 + + + letters-84 + 85 + + + letters-85 + 86 + + + letters-86 + 87 + + + letters-87 + 88 + + + letters-88 + 89 + + + letters-89 + 90 + + + letters-90 + 91 + + + letters-91 + 92 + + + letters-92 + 93 + + + letters-93 + 94 + + + letters-94 + 95 + + + letters-95 + 96 + + + letters-96 + 97 + + + letters-97 + 98 + + + letters-98 + 99 + + + letters-99 + 100 + + + letters-100 + 999 + + + + + + + words-0 + 1 + + + words-1 + 2 + + + words-2 + 3 + + + words-3 + 4 + + + words-4 + 5 + + + words-5 + 6 + + + words-6 + 7 + + + words-7 + 8 + + + words-8 + 9 + + + words-9 + 10 + + + words-10 + 11 + + + words-11 + 12 + + + words-12 + 13 + + + words-13 + 14 + + + words-14 + 15 + + + words-15 + 16 + + + words-16 + 17 + + + words-17 + 18 + + + words-18 + 19 + + + words-19 + 20 + + + words-20 + 21 + + + words-21 + 22 + + + words-22 + 23 + + + words-23 + 24 + + + words-24 + 25 + + + words-25 + 26 + + + words-26 + 27 + + + words-27 + 28 + + + words-28 + 29 + + + words-29 + 30 + + + words-30 + 31 + + + words-31 + 32 + + + words-32 + 33 + + + words-33 + 34 + + + words-34 + 35 + + + words-35 + 36 + + + words-36 + 37 + + + words-37 + 38 + + + words-38 + 39 + + + words-39 + 40 + + + words-40 + 41 + + + words-41 + 42 + + + words-42 + 43 + + + words-43 + 44 + + + words-44 + 45 + + + words-45 + 46 + + + words-46 + 47 + + + words-47 + 48 + + + words-48 + 49 + + + words-49 + 50 + + + words-50 + 999 + + + + + + + reading-0 + 1 + + + reading-1 + 2 + + + reading-2 + 3 + + + reading-3 + 4 + + + reading-4 + 5 + + + reading-5 + 6 + + + reading-6 + 7 + + + reading-7 + 8 + + + reading-8 + 9 + + + reading-9 + 10 + + + reading-10 + 11 + + + reading-11 + 12 + + + reading-12 + 13 + + + reading-13 + 14 + + + reading-14 + 15 + + + reading-15 + 16 + + + reading-16 + 17 + + + reading-17 + 18 + + + reading-18 + 19 + + + reading-19 + 20 + + + reading-20 + 21 + + + reading-21 + 22 + + + reading-22 + 23 + + + reading-23 + 24 + + + reading-24 + 25 + + + reading-25 + 26 + + + reading-26 + 27 + + + reading-27 + 28 + + + reading-28 + 29 + + + reading-29 + 30 + + + reading-30 + 31 + + + reading-31 + 32 + + + reading-32 + 33 + + + reading-33 + 34 + + + reading-34 + 35 + + + reading-35 + 36 + + + reading-36 + 37 + + + reading-37 + 38 + + + reading-38 + 39 + + + reading-39 + 40 + + + reading-40 + 41 + + + reading-41 + 42 + + + reading-42 + 43 + + + reading-43 + 44 + + + reading-44 + 45 + + + reading-45 + 46 + + + reading-46 + 47 + + + reading-47 + 48 + + + reading-48 + 49 + + + reading-49 + 50 + + + reading-50 + 51 + + + reading-51 + 52 + + + reading-52 + 53 + + + reading-53 + 54 + + + reading-54 + 55 + + + reading-55 + 56 + + + reading-56 + 57 + + + reading-57 + 58 + + + reading-58 + 59 + + + reading-59 + 60 + + + reading-60 + 61 + + + reading-61 + 62 + + + reading-62 + 63 + + + reading-63 + 64 + + + reading-64 + 65 + + + reading-65 + 66 + + + reading-66 + 67 + + + reading-67 + 68 + + + reading-68 + 69 + + + reading-69 + 70 + + + reading-70 + 71 + + + reading-71 + 72 + + + reading-72 + 73 + + + reading-73 + 74 + + + reading-74 + 75 + + + reading-75 + 76 + + + reading-76 + 77 + + + reading-77 + 78 + + + reading-78 + 79 + + + reading-79 + 80 + + + reading-80 + 81 + + + reading-81 + 82 + + + reading-82 + 83 + + + reading-83 + 84 + + + reading-84 + 85 + + + reading-85 + 86 + + + reading-86 + 87 + + + reading-87 + 88 + + + reading-88 + 89 + + + reading-89 + 90 + + + reading-90 + 91 + + + reading-91 + 92 + + + reading-92 + 93 + + + reading-93 + 94 + + + reading-94 + 95 + + + reading-95 + 96 + + + reading-96 + 97 + + + reading-97 + 98 + + + reading-98 + 99 + + + reading-99 + 100 + + + reading-100 + 101 + + + reading-101 + 102 + + + reading-102 + 103 + + + reading-103 + 104 + + + reading-104 + 105 + + + reading-105 + 106 + + + reading-106 + 107 + + + reading-107 + 108 + + + reading-108 + 999 + + + + + + + numbers-0 + 1 + + + numbers-1 + 2 + + + numbers-2 + 3 + + + numbers-3 + 4 + + + numbers-4 + 5 + + + numbers-5 + 6 + + + numbers-6 + 7 + + + numbers-7 + 8 + + + numbers-8 + 9 + + + numbers-9 + 10 + + + numbers-10 + 11 + + + numbers-11 + 12 + + + numbers-12 + 13 + + + numbers-13 + 14 + + + numbers-14 + 15 + + + numbers-15 + 16 + + + numbers-16 + 17 + + + numbers-17 + 18 + + + numbers-18 + 19 + + + numbers-19 + 20 + + + numbers-20 + 21 + + + numbers-21 + 22 + + + numbers-22 + 23 + + + numbers-23 + 24 + + + numbers-24 + 25 + + + numbers-25 + 26 + + + numbers-26 + 27 + + + numbers-27 + 28 + + + numbers-28 + 29 + + + numbers-29 + 30 + + + numbers-30 + 31 + + + numbers-31 + 32 + + + numbers-32 + 33 + + + numbers-33 + 34 + + + numbers-34 + 35 + + + numbers-35 + 36 + + + numbers-36 + 37 + + + numbers-37 + 38 + + + numbers-38 + 39 + + + numbers-39 + 40 + + + numbers-40 + 41 + + + numbers-41 + 42 + + + numbers-42 + 43 + + + numbers-43 + 44 + + + numbers-44 + 45 + + + numbers-45 + 46 + + + numbers-46 + 47 + + + numbers-47 + 48 + + + numbers-48 + 49 + + + numbers-49 + 50 + + + numbers-50 + 999 + + + + + + + arithmetic-0 + 1 + + + arithmetic-1 + 2 + + + arithmetic-2 + 3 + + + arithmetic-3 + 4 + + + arithmetic-4 + 5 + + + arithmetic-5 + 6 + + + arithmetic-6 + 7 + + + arithmetic-7 + 8 + + + arithmetic-8 + 9 + + + arithmetic-9 + 10 + + + arithmetic-10 + 11 + + + arithmetic-11 + 12 + + + arithmetic-12 + 13 + + + arithmetic-13 + 14 + + + arithmetic-14 + 15 + + + arithmetic-15 + 16 + + + arithmetic-16 + 17 + + + arithmetic-17 + 18 + + + arithmetic-18 + 19 + + + arithmetic-19 + 20 + + + arithmetic-20 + 999 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 4e86dad75ce34be0980e8b47fbb0d2469660d83a Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Mon, 23 Feb 2026 13:29:19 +0100 Subject: [PATCH 03/14] Update accepted apk size --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From d58b7a76f3c8100748df57a4bde5c1485fe519a8 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Mon, 23 Feb 2026 15:55:54 +0100 Subject: [PATCH 04/14] Add missing desugaring --- experimental/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/experimental/build.gradle.kts b/experimental/build.gradle.kts index 7cb41774a97..9e9015cf623 100644 --- a/experimental/build.gradle.kts +++ b/experimental/build.gradle.kts @@ -22,6 +22,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -39,6 +40,8 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar) + implementation(project(":androidshared")) implementation(project(":strings")) From fa67f68b2c13780e18b46bb6b613bf5923271019 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Wed, 25 Feb 2026 10:19:08 +0100 Subject: [PATCH 05/14] Move TimedGridSummaryAnswerCreator to the new module --- .../experimental/timedgrid/TimedGridWidget.kt | 34 ++++++++++++++++- .../TimedGridSummaryAnswerCreator.kt | 38 ++++++++----------- 2 files changed, 47 insertions(+), 25 deletions(-) rename {collect_app/src/main/java/org/odk/collect/android => experimental/src/main/java/org/odk/collect}/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt (71%) diff --git a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt index c5a56a65d0a..215b0c854f9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt +++ b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt @@ -8,6 +8,8 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.javarosa.core.model.FormIndex +import org.javarosa.core.model.IFormElement import org.javarosa.core.model.data.IAnswerData import org.javarosa.core.model.data.MultipleItemsData import org.javarosa.core.model.data.helper.Selection @@ -16,13 +18,17 @@ 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.QuestionWidget +import org.odk.collect.android.widgets.StringWidget import org.odk.collect.android.widgets.items.ItemsWidgetUtils.loadItemsAndHandleErrors import org.odk.collect.experimental.timedgrid.FinishType +import org.odk.collect.experimental.timedgrid.FormAnswerRefresher +import org.odk.collect.experimental.timedgrid.FormControllerFacade import org.odk.collect.experimental.timedgrid.GridItem import org.odk.collect.experimental.timedgrid.NavigationAwareWidget import org.odk.collect.experimental.timedgrid.OngoingAssessmentWarningDialogFragment import org.odk.collect.experimental.timedgrid.TimedGridState import org.odk.collect.experimental.timedgrid.TimedGridSummary +import org.odk.collect.experimental.timedgrid.TimedGridSummaryAnswerCreator import org.odk.collect.experimental.timedgrid.TimedGridViewModel import org.odk.collect.experimental.timedgrid.TimedGridWidgetConfiguration import org.odk.collect.experimental.timedgrid.Timer @@ -50,8 +56,32 @@ class TimedGridWidget( private val summaryAnswerCreator = TimedGridSummaryAnswerCreator( formEntryPrompt, - formEntryViewModel, - context.takeIf { it is FormFillingActivity } as FormFillingActivity? + object : FormControllerFacade { + override fun getFormElements(): List? { + return formEntryViewModel.formController.getFormDef()?.children + } + + 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() + } + } + } ) private val items = loadItemsAndHandleErrors( diff --git a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt similarity index 71% rename from collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt rename to experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt index 1bff72befc2..55147107601 100644 --- a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.experimental.timedgrid +package org.odk.collect.experimental.timedgrid import org.javarosa.core.model.FormIndex import org.javarosa.core.model.GroupDef @@ -10,15 +10,11 @@ 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 -import org.odk.collect.android.activities.FormFillingActivity -import org.odk.collect.android.formentry.FormEntryViewModel -import org.odk.collect.android.widgets.StringWidget -import org.odk.collect.experimental.timedgrid.TimedGridSummary class TimedGridSummaryAnswerCreator( val formEntryPrompt: FormEntryPrompt, - val formEntryViewModel: FormEntryViewModel, - val formFillingActivity: FormFillingActivity? + val formControllerFacade: FormControllerFacade, + val formAnswerRefresher: FormAnswerRefresher ) { companion object { val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""timed-grid-answer\((.+),(.+)\)""") @@ -27,7 +23,7 @@ class TimedGridSummaryAnswerCreator( fun answerSummaryQuestions(summary: TimedGridSummary) { val timedGridQuestionId = formEntryPrompt.index.reference.toString(false) - forEachFormQuestionDef(formEntryViewModel.formController.getFormDef()?.children) { questionIndex, questionDef -> + forEachFormQuestionDef(formControllerFacade.getFormElements()) { questionIndex, questionDef -> val summaryQuestionMatch = SUMMARY_QUESTION_APPEARANCE_REGEX.find(questionDef.appearanceAttr ?: "") @@ -37,21 +33,8 @@ class TimedGridSummaryAnswerCreator( if (referencedQuestion == timedGridQuestionId) { val answer = getSummaryAnswer(metadataName, summary) - - formEntryViewModel.formController.saveOneScreenAnswer( - questionIndex, - answer, - false - ) - - formFillingActivity?.currentViewIfODKView?.widgets - ?.filterIsInstance() - ?.find { widget -> widget.formEntryPrompt.index == questionIndex } - ?.apply { - setDisplayValueFromModel() - widgetValueChanged() - showAnswerContainer() - } + formControllerFacade.saveAnswer(questionIndex, answer) + formAnswerRefresher.refreshAnswer(questionIndex) } } } @@ -87,3 +70,12 @@ class TimedGridSummaryAnswerCreator( } } } + +interface FormControllerFacade { + fun getFormElements(): List? + fun saveAnswer(index: FormIndex, answer: IAnswerData) +} + +interface FormAnswerRefresher { + fun refreshAnswer(index: FormIndex) +} From 69e1816bb21dec209cc20fdb9da926a785ddd34b Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 12:09:47 +0100 Subject: [PATCH 06/14] Use TimedGridWidgetDelegate to move code to submodule --- .../experimental/timedgrid/TimedGridWidget.kt | 331 ++---------------- .../TimedGridSummaryAnswerCreator.kt | 6 +- .../timedgrid/TimedGridWidgetDelegate.kt | 281 +++++++++++++++ 3 files changed, 319 insertions(+), 299 deletions(-) create mode 100644 experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt index 215b0c854f9..895fcc67d75 100644 --- a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt +++ b/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt @@ -2,17 +2,10 @@ package org.odk.collect.android.experimental.timedgrid import android.annotation.SuppressLint import android.content.Context -import android.view.LayoutInflater -import android.view.View import androidx.fragment.app.DialogFragment -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.javarosa.core.model.FormIndex import org.javarosa.core.model.IFormElement 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 org.odk.collect.android.activities.FormFillingActivity import org.odk.collect.android.formentry.FormEntryViewModel @@ -20,21 +13,12 @@ import org.odk.collect.android.formentry.questions.QuestionDetails import org.odk.collect.android.widgets.QuestionWidget import org.odk.collect.android.widgets.StringWidget import org.odk.collect.android.widgets.items.ItemsWidgetUtils.loadItemsAndHandleErrors -import org.odk.collect.experimental.timedgrid.FinishType import org.odk.collect.experimental.timedgrid.FormAnswerRefresher import org.odk.collect.experimental.timedgrid.FormControllerFacade -import org.odk.collect.experimental.timedgrid.GridItem import org.odk.collect.experimental.timedgrid.NavigationAwareWidget import org.odk.collect.experimental.timedgrid.OngoingAssessmentWarningDialogFragment -import org.odk.collect.experimental.timedgrid.TimedGridState -import org.odk.collect.experimental.timedgrid.TimedGridSummary -import org.odk.collect.experimental.timedgrid.TimedGridSummaryAnswerCreator -import org.odk.collect.experimental.timedgrid.TimedGridViewModel -import org.odk.collect.experimental.timedgrid.TimedGridWidgetConfiguration -import org.odk.collect.experimental.timedgrid.Timer -import org.odk.collect.experimental.timedgrid.TimerProvider +import org.odk.collect.experimental.timedgrid.TimedGridWidgetDelegate import kotlin.jvm.java -import kotlin.time.Duration.Companion.milliseconds @SuppressLint("ViewConstructor") class TimedGridWidget( @@ -43,307 +27,62 @@ class TimedGridWidget( dependencies: Dependencies, formEntryViewModel: FormEntryViewModel ) : QuestionWidget(context, dependencies, questionDetails), NavigationAwareWidget { - private val viewModel = ViewModelProvider(context as ViewModelStoreOwner)[TimedGridViewModel::class.java] - private val timer: Timer = TimerProvider.get() - - // Parsed prompt configuration (type, duration, etc.). - private val config = TimedGridWidgetConfiguration.fromPrompt( - questionDetails.prompt - ).also { - timer.setUpDuration(it.duration.inWholeMilliseconds) - } - - private val summaryAnswerCreator = - TimedGridSummaryAnswerCreator( - formEntryPrompt, - object : FormControllerFacade { - override fun getFormElements(): List? { - return formEntryViewModel.formController.getFormDef()?.children - } - - 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() - } - } - } - ) - private val items = loadItemsAndHandleErrors( this, questionDetails.prompt, formEntryViewModel ) - // Filtered items to display in the grid. - private val gridItems = items - .filter { it.value != config.allAnsweredCorrectly } - .map { GridItem(it.value, questionDetails.prompt.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() - - // Trigger view creation. - init { - render() - } - - override fun onCreateWidgetView( - context: Context, - prompt: FormEntryPrompt, - answerFontSize: Int - ): View { - val inflater = LayoutInflater.from(context) - val root = renderer.inflateView(inflater, this) - 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( - org.odk.collect.experimental.R.string.timed_grid_time_left, - secondsRemaining - ) - renderer.updateTimer(timeLeftText) - summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + private val widgetDelegate = TimedGridWidgetDelegate( + context, + questionDetails.prompt, + items, + object : FormControllerFacade { + override fun getFormElements(): List? { + return formEntryViewModel.formController.getFormDef()?.children } - renderer.updateUIForState(state) - } ?: run { - readSavedItems() - renderer.updateUIForState(state) - } - - return root - } - - fun onTimerTick(millisUntilFinished: Long) { - val secondsRemaining = millisUntilFinished.milliseconds.inWholeSeconds - val timeLeftText = context.getString( - org.odk.collect.experimental.R.string.timed_grid_time_left, - secondsRemaining - ) - renderer.updateTimer(timeLeftText) - summaryBuilder.secondsRemaining(secondsRemaining.toInt()) - } - - fun onTimerFinish() { - val timeLeftText = context.getString( - org.odk.collect.experimental.R.string.timed_grid_time_left, - 0 - ) - renderer.updateTimer(timeLeftText) - if (config.strict) { - completeAssessment() - } - } - - private fun saveTimerState() { - viewModel.saveTimedGridState( - formEntryPrompt.index, - TimedGridViewModel.TimedGridTimerState( - millisRemaining = timer.getMillisRemaining(), - state = state, - toggledAnswers = renderer.getToggledAnswers(), - lastAttempted = renderer.getLastSelectedLastItemValue() - ) - ) - } - - 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 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 onEarlyFinishPress() { - when (config.finish) { - FinishType.CONFIRM_AND_PICK -> { - showConfirmFinishDialog { completeAssessment() } + override fun saveAnswer(index: FormIndex, answer: IAnswerData) { + formEntryViewModel.formController.saveOneScreenAnswer(index, answer, false) } - FinishType.CONFIRM_AND_AUTO_PICK -> { - showConfirmFinishDialog { - completeAssessment(true) + }, + 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() } } - FinishType.AUTO_PICK_NO_CONFIRM -> { - completeAssessment(true) - } - } - } - - private fun showConfirmFinishDialog(onConfirm: () -> Unit) { - MaterialAlertDialogBuilder(context) - .setTitle(org.odk.collect.experimental.R.string.early_finish_title) - .setMessage(org.odk.collect.experimental.R.string.early_finish_message) - .setPositiveButton(org.odk.collect.experimental.R.string.end_test) { _, _ -> - onConfirm() - } - .setNegativeButton(org.odk.collect.experimental.R.string.continue_test, null) - .show() - } - - private fun autoPickLastItem() { - val lastItemValue = gridItems.lastOrNull()?.value - if (lastItemValue != null) { - renderer.restoreAnswers(renderer.getToggledAnswers(), lastItemValue) } + ) { + widgetValueChanged() } - 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 } - ) - } + init { + render() } - /** - * 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". - */ - override fun getAnswer(): IAnswerData { - val selectedValues = renderer.getToggledAnswers().ifEmpty { - setOf(config.allAnsweredCorrectly) - }.toMutableList() + override fun onCreateWidgetView(context: Context, prompt: FormEntryPrompt, answerFontSize: Int) = widgetDelegate.onCreateWidgetView(this) - renderer.getLastSelectedLastItemValue()?.let { - selectedValues.add(it) - } - - val choicesByValue = items.associateBy { it.value } - return MultipleItemsData(selectedValues.map { selectedValue -> Selection(choicesByValue[selectedValue]) }) - } + override fun getAnswer() = widgetDelegate.getAnswer() override fun clearAnswer() {} + override fun setOnLongClickListener(l: OnLongClickListener?) {} override fun onDetachedFromWindow() { super.onDetachedFromWindow() - // Ensure state is saved when widget is removed - saveTimerState() - timer.cancel() + widgetDelegate.onDetachedFromWindow() } - override fun shouldBlockNavigation(): Boolean = - state == TimedGridState.IN_PROGRESS || - state == TimedGridState.PAUSED || - state == TimedGridState.COMPLETED_NO_LAST_ITEM + override fun shouldBlockNavigation(): Boolean = widgetDelegate.shouldBlockNavigation() - override fun getWarningDialog(): Class = - OngoingAssessmentWarningDialogFragment::class.java + override fun getWarningDialog(): Class = OngoingAssessmentWarningDialogFragment::class.java } diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt index 55147107601..f4df572e010 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -12,9 +12,9 @@ import org.javarosa.core.model.instance.TreeReference import org.javarosa.form.api.FormEntryPrompt class TimedGridSummaryAnswerCreator( - val formEntryPrompt: FormEntryPrompt, - val formControllerFacade: FormControllerFacade, - val formAnswerRefresher: FormAnswerRefresher + private val formEntryPrompt: FormEntryPrompt, + private val formControllerFacade: FormControllerFacade, + private val formAnswerRefresher: FormAnswerRefresher ) { companion object { val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""timed-grid-answer\((.+),(.+)\)""") diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt new file mode 100644 index 00000000000..c22dae23a31 --- /dev/null +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt @@ -0,0 +1,281 @@ +package org.odk.collect.experimental.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.SelectChoice +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, + private val items: List, + formControllerFacade: FormControllerFacade, + formAnswerRefresher: FormAnswerRefresher, + private val widgetValueChanged: () -> Unit +) { + private val viewModel = ViewModelProvider(context as ViewModelStoreOwner)[TimedGridViewModel::class.java] + private val timer: Timer = TimerProvider.get() + + // 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( + org.odk.collect.experimental.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( + org.odk.collect.experimental.R.string.timed_grid_time_left, + secondsRemaining + ) + renderer.updateTimer(timeLeftText) + summaryBuilder.secondsRemaining(secondsRemaining.toInt()) + } + + private fun onTimerFinish() { + val timeLeftText = context.getString( + org.odk.collect.experimental.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(org.odk.collect.experimental.R.string.early_finish_title) + .setMessage(org.odk.collect.experimental.R.string.early_finish_message) + .setPositiveButton(org.odk.collect.experimental.R.string.end_test) { _, _ -> + onConfirm() + } + .setNegativeButton(org.odk.collect.experimental.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(): Boolean = + state == TimedGridState.IN_PROGRESS || + state == TimedGridState.PAUSED || + state == TimedGridState.COMPLETED_NO_LAST_ITEM +} \ No newline at end of file From 56ada817bdca38600fc5a07f8c961fcf8b5e2847 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 12:12:45 +0100 Subject: [PATCH 07/14] Move TimedGridWidget to widgets package --- .../timedgrid => widgets}/TimedGridWidget.kt | 9 +++------ .../org/odk/collect/android/widgets/WidgetFactory.java | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) rename collect_app/src/main/java/org/odk/collect/android/{experimental/timedgrid => widgets}/TimedGridWidget.kt (90%) diff --git a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt similarity index 90% rename from collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt rename to collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt index 895fcc67d75..695c9572e64 100644 --- a/collect_app/src/main/java/org/odk/collect/android/experimental/timedgrid/TimedGridWidget.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/TimedGridWidget.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.experimental.timedgrid +package org.odk.collect.android.widgets import android.annotation.SuppressLint import android.content.Context @@ -10,15 +10,12 @@ 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.QuestionWidget -import org.odk.collect.android.widgets.StringWidget -import org.odk.collect.android.widgets.items.ItemsWidgetUtils.loadItemsAndHandleErrors +import org.odk.collect.android.widgets.items.ItemsWidgetUtils import org.odk.collect.experimental.timedgrid.FormAnswerRefresher import org.odk.collect.experimental.timedgrid.FormControllerFacade import org.odk.collect.experimental.timedgrid.NavigationAwareWidget import org.odk.collect.experimental.timedgrid.OngoingAssessmentWarningDialogFragment import org.odk.collect.experimental.timedgrid.TimedGridWidgetDelegate -import kotlin.jvm.java @SuppressLint("ViewConstructor") class TimedGridWidget( @@ -27,7 +24,7 @@ class TimedGridWidget( dependencies: Dependencies, formEntryViewModel: FormEntryViewModel ) : QuestionWidget(context, dependencies, questionDetails), NavigationAwareWidget { - private val items = loadItemsAndHandleErrors( + private val items = ItemsWidgetUtils.loadItemsAndHandleErrors( this, questionDetails.prompt, formEntryViewModel ) 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 61e70f5d196..b11bd09ca3c 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 @@ -26,7 +26,6 @@ import org.javarosa.core.model.Constants; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.experimental.timedgrid.TimedGridWidget; import org.odk.collect.android.formentry.FormEntryViewModel; import org.odk.collect.android.formentry.PrinterWidgetViewModel; import org.odk.collect.android.formentry.questions.QuestionDetails; From 2eab2f421bcda39e702e1a1e03d006b6fb66f631 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 12:17:33 +0100 Subject: [PATCH 08/14] Enable code style analysis --- experimental/build.gradle.kts | 2 ++ .../experimental/timedgrid/TimedGridWidgetDelegate.kt | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/experimental/build.gradle.kts b/experimental/build.gradle.kts index 9e9015cf623..7e26e79ca0c 100644 --- a/experimental/build.gradle.kts +++ b/experimental/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlinAndroid) } +apply(from = "../config/quality.gradle") + android { namespace = "org.odk.collect.experimental" diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt index c22dae23a31..5a231adfd5e 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt +++ b/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt @@ -276,6 +276,6 @@ class TimedGridWidgetDelegate( fun shouldBlockNavigation(): Boolean = state == TimedGridState.IN_PROGRESS || - state == TimedGridState.PAUSED || - state == TimedGridState.COMPLETED_NO_LAST_ITEM -} \ No newline at end of file + state == TimedGridState.PAUSED || + state == TimedGridState.COMPLETED_NO_LAST_ITEM +} From 3d5626cfd9f70506aef6ca6efe4fbe9c217f8c62 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 12:40:02 +0100 Subject: [PATCH 09/14] Rename module --- collect_app/build.gradle | 2 +- .../feature/experimental/timedgrid/FakeTimer.kt | 2 +- .../experimental/timedgrid/TimedGridHelpers.kt | 12 ++++++------ .../experimental/timedgrid/TimedGridTest.kt | 12 ++++++------ .../odk/collect/android/formentry/ODKView.java | 2 +- .../odk/collect/android/utilities/Appearances.kt | 2 -- .../collect/android/widgets/TimedGridWidget.kt | 10 +++++----- settings.gradle | 2 +- {experimental => timedgrid}/.gitignore | 0 {experimental => timedgrid}/build.gradle.kts | 2 +- .../src/main/AndroidManifest.xml | 0 .../org/odk/collect}/timedgrid/AssessmentType.kt | 2 +- .../timedgrid/CommonTimedGridRenderer.kt | 4 ++-- .../org/odk/collect}/timedgrid/FinishType.kt | 2 +- .../collect}/timedgrid/NavigationAwareWidget.kt | 2 +- .../OngoingAssessmentWarningDialogFragment.kt | 3 +-- .../collect}/timedgrid/PausableCountDownTimer.kt | 2 +- .../odk/collect}/timedgrid/TimedGridRenderer.kt | 2 +- .../org/odk/collect}/timedgrid/TimedGridState.kt | 2 +- .../odk/collect}/timedgrid/TimedGridSummary.kt | 2 +- .../timedgrid/TimedGridSummaryAnswerCreator.kt | 2 +- .../odk/collect}/timedgrid/TimedGridViewModel.kt | 2 +- .../timedgrid/TimedGridWidgetConfiguration.kt | 2 +- .../timedgrid/TimedGridWidgetDelegate.kt | 16 ++++++++-------- .../collect}/timedgrid/TimedGridWidgetLayout.kt | 7 +++---- .../java/org/odk/collect}/timedgrid/Timer.kt | 2 +- .../color/timed_grid_button_tint_selector.xml | 0 .../main/res/drawable/row_number_background.xml | 0 .../src/main/res/layout/timed_grid.xml | 0 .../main/res/layout/timed_grid_item_button.xml | 0 .../src/main/res/layout/timed_grid_item_row.xml | 0 .../src/main/res/values/colors.xml | 0 .../src/main/res/values/dimens.xml | 0 .../src/main/res/values/strings.xml | 0 .../src/main/res/values/styles.xml | 0 35 files changed, 47 insertions(+), 51 deletions(-) rename {experimental => timedgrid}/.gitignore (100%) rename {experimental => timedgrid}/build.gradle.kts (96%) rename {experimental => timedgrid}/src/main/AndroidManifest.xml (100%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/AssessmentType.kt (89%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/CommonTimedGridRenderer.kt (98%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/FinishType.kt (90%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/NavigationAwareWidget.kt (88%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/OngoingAssessmentWarningDialogFragment.kt (86%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/PausableCountDownTimer.kt (97%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridRenderer.kt (93%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridState.kt (75%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridSummary.kt (97%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridSummaryAnswerCreator.kt (98%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridViewModel.kt (94%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridWidgetConfiguration.kt (99%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridWidgetDelegate.kt (94%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/TimedGridWidgetLayout.kt (98%) rename {experimental/src/main/java/org/odk/collect/experimental => timedgrid/src/main/java/org/odk/collect}/timedgrid/Timer.kt (89%) rename {experimental => timedgrid}/src/main/res/color/timed_grid_button_tint_selector.xml (100%) rename {experimental => timedgrid}/src/main/res/drawable/row_number_background.xml (100%) rename {experimental => timedgrid}/src/main/res/layout/timed_grid.xml (100%) rename {experimental => timedgrid}/src/main/res/layout/timed_grid_item_button.xml (100%) rename {experimental => timedgrid}/src/main/res/layout/timed_grid_item_row.xml (100%) rename {experimental => timedgrid}/src/main/res/values/colors.xml (100%) rename {experimental => timedgrid}/src/main/res/values/dimens.xml (100%) rename {experimental => timedgrid}/src/main/res/values/strings.xml (100%) rename {experimental => timedgrid}/src/main/res/values/styles.xml (100%) diff --git a/collect_app/build.gradle b/collect_app/build.gradle index e6bbea85d10..55fd3006054 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -274,7 +274,7 @@ dependencies { implementation project(':db') implementation project(':open-rosa') implementation project(':mobile-device-management') - implementation project(':experimental') + implementation project(':timedgrid') if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { implementation project(':mapbox') diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt index e7a99ad0e77..52b33a97c01 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt @@ -2,7 +2,7 @@ package org.odk.collect.android.feature.experimental.timedgrid import android.os.Handler import android.os.Looper -import org.odk.collect.experimental.timedgrid.Timer +import org.odk.collect.timedgrid.Timer class FakeTimer : Timer { private val handler = Handler(Looper.getMainLooper()) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt index 6dceaadece6..13ad6a51544 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt @@ -22,12 +22,12 @@ import java.util.function.Consumer object TimedGridHelpers { fun FormEntryPage.clickStartTestButton(): FormEntryPage { - onView(withId(org.odk.collect.experimental.R.id.button_start)).perform(scrollTo(), click()) + onView(withId(org.odk.collect.timedgrid.R.id.button_start)).perform(scrollTo(), click()) return this } fun FormEntryPage.clickPauseTestButton(): FormEntryPage { - onView(withId(org.odk.collect.experimental.R.id.button_timer)).perform(scrollTo(), click()) + onView(withId(org.odk.collect.timedgrid.R.id.button_timer)).perform(scrollTo(), click()) return this } @@ -43,11 +43,11 @@ object TimedGridHelpers { while (!found) { try { // Try to click Early Finish directly by ID - onView(withId(org.odk.collect.experimental.R.id.button_complete)).perform(scrollTo(), click()) + onView(withId(org.odk.collect.timedgrid.R.id.button_complete)).perform(scrollTo(), click()) found = true } catch (e: Exception) { // If not clickable yet, go to next page - onView(withId(org.odk.collect.experimental.R.id.button_next)).perform(scrollTo(), click()) + onView(withId(org.odk.collect.timedgrid.R.id.button_next)).perform(scrollTo(), click()) } } return this @@ -106,7 +106,7 @@ object TimedGridHelpers { } fun FormEntryPage.clickFinishTestButton(): FormEntryPage { - onView(withId(org.odk.collect.experimental.R.id.button_finish)).perform(scrollTo(), click()) + onView(withId(org.odk.collect.timedgrid.R.id.button_finish)).perform(scrollTo(), click()) return this } @@ -179,7 +179,7 @@ object TimedGridHelpers { } fun assertVisibleRows(count: Int) { - onView(withId(org.odk.collect.experimental.R.id.container_rows)) + onView(withId(org.odk.collect.timedgrid.R.id.container_rows)) .check(assertVisibleChildCount(count)) } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt index 1c4900cfb13..85c3202b521 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt @@ -20,7 +20,7 @@ import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.c import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.selectTestAnswers import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain -import org.odk.collect.experimental.timedgrid.TimerProvider +import org.odk.collect.timedgrid.TimerProvider import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) @@ -271,14 +271,14 @@ class TimedGridTest { .selectTestAnswers(tapped) .also { // Capture time before - val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) - it.clickOnId(org.odk.collect.experimental.R.id.button_timer) + it.clickOnId(org.odk.collect.timedgrid.R.id.button_timer) timer.wait(2) // Capture time after - val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) // Assert that the time has decreased @@ -545,7 +545,7 @@ class TimedGridTest { .selectTestAnswers(tapped) .also { // Capture timer before rotation - val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) // Rotate device @@ -553,7 +553,7 @@ class TimedGridTest { timer.wait(5) // Capture timer after rotation - val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.experimental.R.id.button_timer) + val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) // Assert timer is still running 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 8e7ad706689..f62a2e95131 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 @@ -87,10 +87,10 @@ import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton; import org.odk.collect.audioclips.PlaybackFailedException; import org.odk.collect.audiorecorder.recording.AudioRecorder; -import org.odk.collect.experimental.timedgrid.NavigationAwareWidget; 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 java.io.File; import java.io.Serializable; 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 384730e9c29..ff73a9d2843 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,8 +91,6 @@ object Appearances { const val MASKED = "masked" const val COUNTER = "counter" const val MULTILINE = "multiline" - - // Experimental const val TIMED_GRID = "timed-grid" // Get appearance hint and clean it up so it is lower case, without the search function and never null. 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 index 695c9572e64..8aba56ee36c 100644 --- 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 @@ -11,11 +11,11 @@ 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.experimental.timedgrid.FormAnswerRefresher -import org.odk.collect.experimental.timedgrid.FormControllerFacade -import org.odk.collect.experimental.timedgrid.NavigationAwareWidget -import org.odk.collect.experimental.timedgrid.OngoingAssessmentWarningDialogFragment -import org.odk.collect.experimental.timedgrid.TimedGridWidgetDelegate +import org.odk.collect.timedgrid.FormAnswerRefresher +import org.odk.collect.timedgrid.FormControllerFacade +import org.odk.collect.timedgrid.NavigationAwareWidget +import org.odk.collect.timedgrid.OngoingAssessmentWarningDialogFragment +import org.odk.collect.timedgrid.TimedGridWidgetDelegate @SuppressLint("ViewConstructor") class TimedGridWidget( diff --git a/settings.gradle b/settings.gradle index cd9a0f96cd5..6505a1a7f4b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,7 +41,7 @@ include ':web-page' include ':db' include ':open-rosa' include ':mobile-device-management' -include ':experimental' +include ':timedgrid' apply from: 'secrets.gradle' if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { diff --git a/experimental/.gitignore b/timedgrid/.gitignore similarity index 100% rename from experimental/.gitignore rename to timedgrid/.gitignore diff --git a/experimental/build.gradle.kts b/timedgrid/build.gradle.kts similarity index 96% rename from experimental/build.gradle.kts rename to timedgrid/build.gradle.kts index 7e26e79ca0c..85162f0b135 100644 --- a/experimental/build.gradle.kts +++ b/timedgrid/build.gradle.kts @@ -6,7 +6,7 @@ plugins { apply(from = "../config/quality.gradle") android { - namespace = "org.odk.collect.experimental" + namespace = "org.odk.collect.timedgrid" compileSdk = libs.versions.compileSdk.get().toInt() diff --git a/experimental/src/main/AndroidManifest.xml b/timedgrid/src/main/AndroidManifest.xml similarity index 100% rename from experimental/src/main/AndroidManifest.xml rename to timedgrid/src/main/AndroidManifest.xml diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt similarity index 89% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt index e086de25f74..ac3867cfa30 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/AssessmentType.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/AssessmentType.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid enum class AssessmentType( private val rendererFactory: () -> TimedGridRenderer diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt similarity index 98% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt index bd55d46873d..b5df28d5682 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/CommonTimedGridRenderer.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/CommonTimedGridRenderer.kt @@ -1,9 +1,9 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import org.odk.collect.experimental.databinding.TimedGridBinding +import org.odk.collect.timedgrid.databinding.TimedGridBinding /** * Common renderer for timed grid modes that share the same UI and interaction pattern: diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt similarity index 90% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt index eaf57b2fdda..5ff6026bffb 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/FinishType.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/FinishType.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid enum class FinishType(val code: Int) { /** User confirms and picks last attempted item manually */ diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt similarity index 88% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt index 4c7cfd89298..fcf93d9a522 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/NavigationAwareWidget.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import androidx.fragment.app.DialogFragment diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt similarity index 86% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt index afc7c6bf03e..70cebfcf4fb 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/OngoingAssessmentWarningDialogFragment.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt @@ -1,10 +1,9 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.app.Dialog import android.os.Bundle import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.odk.collect.experimental.R class OngoingAssessmentWarningDialogFragment : DialogFragment() { diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt similarity index 97% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt index 4be8cf370f7..28b45ff51fb 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/PausableCountDownTimer.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.os.CountDownTimer diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt similarity index 93% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt index 35b4ddf5432..b184924118e 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridRenderer.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridRenderer.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.view.LayoutInflater import android.view.View diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt similarity index 75% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt index 83187b1fb09..fdeb60ef4e5 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridState.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridState.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid // Lifecycle states for the timed grid widget. enum class TimedGridState { diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt similarity index 97% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt index 046667a6668..54f37629c93 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummary.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummary.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid data class TimedGridSummary( val secondsRemaining: Int, diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt similarity index 98% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt index f4df572e010..7a0de8476dc 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridSummaryAnswerCreator.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import org.javarosa.core.model.FormIndex import org.javarosa.core.model.GroupDef diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt similarity index 94% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt index 6c642a10908..bd2ff79ee77 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridViewModel.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridViewModel.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import androidx.lifecycle.ViewModel import org.javarosa.core.model.FormIndex diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt similarity index 99% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt index e0fa0db4719..f21dc8fc81e 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetConfiguration.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetConfiguration.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import androidx.core.text.isDigitsOnly import org.javarosa.form.api.FormEntryPrompt diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt similarity index 94% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt index 5a231adfd5e..8abb4aa43f5 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetDelegate.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.content.Context import android.view.LayoutInflater @@ -79,7 +79,7 @@ class TimedGridWidgetDelegate( } else if (state == TimedGridState.PAUSED) { val secondsRemaining = saved.millisRemaining.milliseconds.inWholeSeconds val timeLeftText = context.getString( - org.odk.collect.experimental.R.string.timed_grid_time_left, + R.string.timed_grid_time_left, secondsRemaining ) renderer.updateTimer(timeLeftText) @@ -122,7 +122,7 @@ class TimedGridWidgetDelegate( private fun onTimerTick(millisUntilFinished: Long) { val secondsRemaining = millisUntilFinished.milliseconds.inWholeSeconds val timeLeftText = context.getString( - org.odk.collect.experimental.R.string.timed_grid_time_left, + R.string.timed_grid_time_left, secondsRemaining ) renderer.updateTimer(timeLeftText) @@ -131,7 +131,7 @@ class TimedGridWidgetDelegate( private fun onTimerFinish() { val timeLeftText = context.getString( - org.odk.collect.experimental.R.string.timed_grid_time_left, + R.string.timed_grid_time_left, 0 ) renderer.updateTimer(timeLeftText) @@ -186,12 +186,12 @@ class TimedGridWidgetDelegate( private fun showConfirmFinishDialog(onConfirm: () -> Unit) { MaterialAlertDialogBuilder(context) - .setTitle(org.odk.collect.experimental.R.string.early_finish_title) - .setMessage(org.odk.collect.experimental.R.string.early_finish_message) - .setPositiveButton(org.odk.collect.experimental.R.string.end_test) { _, _ -> + .setTitle(R.string.early_finish_title) + .setMessage(R.string.early_finish_message) + .setPositiveButton(R.string.end_test) { _, _ -> onConfirm() } - .setNegativeButton(org.odk.collect.experimental.R.string.continue_test, null) + .setNegativeButton(R.string.continue_test, null) .show() } diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt similarity index 98% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt index 84b9b214870..3d7d24b928d 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/TimedGridWidgetLayout.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetLayout.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid import android.content.res.ColorStateList import android.graphics.Paint @@ -9,9 +9,8 @@ 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.experimental.R -import org.odk.collect.experimental.databinding.TimedGridItemButtonBinding -import org.odk.collect.experimental.databinding.TimedGridItemRowBinding +import org.odk.collect.timedgrid.databinding.TimedGridItemButtonBinding +import org.odk.collect.timedgrid.databinding.TimedGridItemRowBinding data class GridItem(val value: String, val text: String) diff --git a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt similarity index 89% rename from experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt rename to timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt index 6df440c1c42..66cf025235e 100644 --- a/experimental/src/main/java/org/odk/collect/experimental/timedgrid/Timer.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt @@ -1,4 +1,4 @@ -package org.odk.collect.experimental.timedgrid +package org.odk.collect.timedgrid interface Timer { fun setUpListeners( diff --git a/experimental/src/main/res/color/timed_grid_button_tint_selector.xml b/timedgrid/src/main/res/color/timed_grid_button_tint_selector.xml similarity index 100% rename from experimental/src/main/res/color/timed_grid_button_tint_selector.xml rename to timedgrid/src/main/res/color/timed_grid_button_tint_selector.xml diff --git a/experimental/src/main/res/drawable/row_number_background.xml b/timedgrid/src/main/res/drawable/row_number_background.xml similarity index 100% rename from experimental/src/main/res/drawable/row_number_background.xml rename to timedgrid/src/main/res/drawable/row_number_background.xml diff --git a/experimental/src/main/res/layout/timed_grid.xml b/timedgrid/src/main/res/layout/timed_grid.xml similarity index 100% rename from experimental/src/main/res/layout/timed_grid.xml rename to timedgrid/src/main/res/layout/timed_grid.xml diff --git a/experimental/src/main/res/layout/timed_grid_item_button.xml b/timedgrid/src/main/res/layout/timed_grid_item_button.xml similarity index 100% rename from experimental/src/main/res/layout/timed_grid_item_button.xml rename to timedgrid/src/main/res/layout/timed_grid_item_button.xml diff --git a/experimental/src/main/res/layout/timed_grid_item_row.xml b/timedgrid/src/main/res/layout/timed_grid_item_row.xml similarity index 100% rename from experimental/src/main/res/layout/timed_grid_item_row.xml rename to timedgrid/src/main/res/layout/timed_grid_item_row.xml diff --git a/experimental/src/main/res/values/colors.xml b/timedgrid/src/main/res/values/colors.xml similarity index 100% rename from experimental/src/main/res/values/colors.xml rename to timedgrid/src/main/res/values/colors.xml diff --git a/experimental/src/main/res/values/dimens.xml b/timedgrid/src/main/res/values/dimens.xml similarity index 100% rename from experimental/src/main/res/values/dimens.xml rename to timedgrid/src/main/res/values/dimens.xml diff --git a/experimental/src/main/res/values/strings.xml b/timedgrid/src/main/res/values/strings.xml similarity index 100% rename from experimental/src/main/res/values/strings.xml rename to timedgrid/src/main/res/values/strings.xml diff --git a/experimental/src/main/res/values/styles.xml b/timedgrid/src/main/res/values/styles.xml similarity index 100% rename from experimental/src/main/res/values/styles.xml rename to timedgrid/src/main/res/values/styles.xml From 3dd9caa8a83087d491b36598d714192d4a77109c Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 12:47:15 +0100 Subject: [PATCH 10/14] Rename canPerformMenuItemClick to beforeMenuItemClick for clarity --- .../odk/collect/android/formentry/FormEntryMenuProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5eabde04b07..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 @@ -35,7 +35,7 @@ class FormEntryMenuProvider( private val backgroundAudioViewModel: BackgroundAudioViewModel, private val settingsProvider: SettingsProvider, private val formEntryMenuClickListener: FormEntryMenuClickListener, - private val canPerformMenuItemClick: () -> Boolean + private val beforeMenuItemClick: () -> Boolean ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.form_menu, menu) @@ -97,7 +97,7 @@ class FormEntryMenuProvider( return true } - if (!canPerformMenuItemClick()) { + if (!beforeMenuItemClick()) { return true } From 92f214de7ab2fdac28a96115766290edb6d59c07 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 26 Feb 2026 13:08:59 +0100 Subject: [PATCH 11/14] Remove tests --- .../experimental/timedgrid/FakeTimer.kt | 62 - .../timedgrid/TimedGridHelpers.kt | 202 - .../experimental/timedgrid/TimedGridTest.kt | 673 --- gradle/libs.versions.toml | 2 - .../main/resources/forms/timed-grid-form.xml | 4401 ----------------- .../timedgrid/PausableCountDownTimer.kt | 14 +- .../timedgrid/TimedGridWidgetDelegate.kt | 2 +- .../java/org/odk/collect/timedgrid/Timer.kt | 24 - 8 files changed, 8 insertions(+), 5372 deletions(-) delete mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt delete mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt delete mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt delete mode 100644 test-forms/src/main/resources/forms/timed-grid-form.xml delete mode 100644 timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt deleted file mode 100644 index 52b33a97c01..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/FakeTimer.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.odk.collect.android.feature.experimental.timedgrid - -import android.os.Handler -import android.os.Looper -import org.odk.collect.timedgrid.Timer - -class FakeTimer : Timer { - private val handler = Handler(Looper.getMainLooper()) - private var millisRemaining: Long = 0 - private var isPaused = false - - private lateinit var onTick: (millisUntilFinished: Long) -> Unit - private lateinit var onFinish: () -> Unit - - override fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { - this.onTick = onTick - this.onFinish = onFinish - } - - override fun setUpDuration(millisRemaining: Long) { - this.millisRemaining = millisRemaining - } - - override fun start(): Timer { - if (!isPaused) { - isPaused = false - return FakeTimer().also { - handler.post { - onTick(millisRemaining) - } - } - } - return this - } - - override fun pause() { - isPaused = true - } - - override fun cancel() { - isPaused = false - } - - override fun getMillisRemaining(): Long { - return millisRemaining - } - - fun wait(seconds: Int) { - if (isPaused) return - - repeat(seconds) { - millisRemaining -= 1000 - if (millisRemaining <= 0) { - millisRemaining = 0 - handler.post { onFinish() } - return - } else { - handler.post { onTick(millisRemaining) } - } - } - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt deleted file mode 100644 index 13ad6a51544..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridHelpers.kt +++ /dev/null @@ -1,202 +0,0 @@ -package org.odk.collect.android.feature.experimental.timedgrid - -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction -import androidx.test.espresso.ViewAssertion -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.scrollTo -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.withId -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.Matcher -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.odk.collect.android.R -import org.odk.collect.android.support.matchers.CustomMatchers.withIndex -import org.odk.collect.android.support.pages.FormEntryPage -import java.util.function.Consumer - -object TimedGridHelpers { - fun FormEntryPage.clickStartTestButton(): FormEntryPage { - onView(withId(org.odk.collect.timedgrid.R.id.button_start)).perform(scrollTo(), click()) - return this - } - - fun FormEntryPage.clickPauseTestButton(): FormEntryPage { - onView(withId(org.odk.collect.timedgrid.R.id.button_timer)).perform(scrollTo(), click()) - return this - } - - fun FormEntryPage.selectTestAnswers(items: List): FormEntryPage { - items.forEach(Consumer { item: String? -> - onView(withIndex(ViewMatchers.withText(item), 0)).perform(click()) - }) - return this - } - - fun FormEntryPage.clickUntilEarlyFinish(): FormEntryPage { - var found = false - while (!found) { - try { - // Try to click Early Finish directly by ID - onView(withId(org.odk.collect.timedgrid.R.id.button_complete)).perform(scrollTo(), click()) - found = true - } catch (e: Exception) { - // If not clickable yet, go to next page - onView(withId(org.odk.collect.timedgrid.R.id.button_next)).perform(scrollTo(), click()) - } - } - return this - } - - fun FormEntryPage.clickForwardButtonWithError(): FormEntryPage { - closeSoftKeyboard() - onView(ViewMatchers.withText(getTranslatedString(org.odk.collect.strings.R.string.form_forward))).perform( - click() - ) - assertNavigationBlockedWarning() - return this - } - - fun FormEntryPage.clickGoToArrowWithError(): FormEntryPage { - onView(withId(R.id.menu_goto)).perform(click()) - assertNavigationBlockedWarning() - return this - } - - fun FormEntryPage.clickProjectSettingsWithError(): FormEntryPage { - onView(ViewMatchers.withText(getTranslatedString(org.odk.collect.strings.R.string.project_settings))).perform( - click() - ) - assertNavigationBlockedWarning() - return this - } - - fun FormEntryPage.assertEarlyFinishDialogAndConfirm(): FormEntryPage { - assertText("Early Finish") - assertText("Do you want to end the test now?") - clickOnText("End Test") - return this - } - - fun FormEntryPage.assertLastAttemptedItemDialogAndConfirm(item: String): FormEntryPage { - clickOnText(item) - assertText("Last Attempted Item") - assertText("Do you want to confirm the last attempted item?") - clickOnText("Yes") - return this - } - - fun FormEntryPage.assertTestEndedEarlyDialogAndConfirm(endAfter: Int): FormEntryPage { - assertText("Test Ended Early") - assertText("You have reached the limit of $endAfter consecutive wrong answers.") - clickOnText("OK") - return this - } - - fun FormEntryPage.assertConsecutiveMistakesDialogAndContinue(endAfter: Int): FormEntryPage { - assertText("Consecutive Mistakes") - assertText("You have made $endAfter consecutive mistakes. Do you want to end the test or continue?") - clickOnText("Continue") - return this - } - - fun FormEntryPage.clickFinishTestButton(): FormEntryPage { - onView(withId(org.odk.collect.timedgrid.R.id.button_finish)).perform(scrollTo(), click()) - return this - } - - private fun FormEntryPage.assertNavigationBlockedWarning() { - assertText("Assessment…") - assertText("You must finish assessment before leaving this screen.") - clickOnText("OK") - } - - /** - * Generate expected correct items string dynamically. - */ - fun expectedCorrectItems( - allItems: List, - tapped: Set, - lastAttemptedItem: String? = null - ): String { - val cutoffIndex = if (lastAttemptedItem != null) { - allItems.indexOf(lastAttemptedItem).takeIf { it != -1 } ?: allItems.lastIndex - } else { - allItems.lastIndex - } - - val subset = allItems.subList(0, cutoffIndex + 1).toMutableList() - - // Remove only the first occurrence of each tapped item - tapped.forEach { tappedItem -> - val idx = subset.indexOf(tappedItem) - if (idx != -1) { - subset.removeAt(idx) - } - } - - return subset.joinToString(", ") - } - - /** - * Returns the list of items not attempted/answered, - * i.e. everything after the lastAttemptedItem in the full list. - */ - fun notAttemptedItems(allItems: List, lastAttemptedItem: String): List { - val index = allItems.indexOf(lastAttemptedItem) - return if (index != -1 && index < allItems.size - 1) { - allItems.subList(index + 1, allItems.size) - } else { - emptyList() - } - } - - fun extractTimeLeft(text: String): Int { - // Example: "Time Left: 58" -> 58 - return text.substringAfter("Time Left: ").trim().toInt() - } - - fun getTextFromView(resId: Int): String { - var text = "" - onView(withId(resId)).perform(object : ViewAction { - override fun getConstraints(): Matcher { - return Matchers.instanceOf(TextView::class.java) - } - - override fun getDescription() = "Get text from a TextView" - - override fun perform(uiController: UiController?, view: View?) { - val tv = view as TextView - text = tv.text.toString() - } - }) - return text - } - - fun assertVisibleRows(count: Int) { - onView(withId(org.odk.collect.timedgrid.R.id.container_rows)) - .check(assertVisibleChildCount(count)) - } - - private fun assertVisibleChildCount(expected: Int): ViewAssertion { - return ViewAssertion { view, noViewFoundException -> - if (noViewFoundException != null) throw noViewFoundException - if (view !is ViewGroup) throw AssertionError("View is not a ViewGroup") - - val visibleCount = (0 until view.childCount) - .map { view.getChildAt(it) } - .count { it.visibility == View.VISIBLE } - - assertThat( - "Expected $expected visible children but was $visibleCount", - visibleCount, - equalTo(expected) - ) - } - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt deleted file mode 100644 index 85c3202b521..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/experimental/timedgrid/TimedGridTest.kt +++ /dev/null @@ -1,673 +0,0 @@ -package org.odk.collect.android.feature.experimental.timedgrid - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertConsecutiveMistakesDialogAndContinue -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertEarlyFinishDialogAndConfirm -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertLastAttemptedItemDialogAndConfirm -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.assertTestEndedEarlyDialogAndConfirm -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickFinishTestButton -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickForwardButtonWithError -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickGoToArrowWithError -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickPauseTestButton -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickProjectSettingsWithError -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickStartTestButton -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.clickUntilEarlyFinish -import org.odk.collect.android.feature.experimental.timedgrid.TimedGridHelpers.selectTestAnswers -import org.odk.collect.android.support.rules.CollectTestRule -import org.odk.collect.android.support.rules.TestRuleChain -import org.odk.collect.timedgrid.TimerProvider -import kotlin.math.roundToInt - -@RunWith(AndroidJUnit4::class) -class TimedGridTest { - - private val rule = CollectTestRule() - - @get:Rule - var copyFormChain: RuleChain = TestRuleChain.chain() - .around(rule) - - private val timer = FakeTimer() - - @Before - fun setup() { - TimerProvider.factory = { timer } - } - - // --- Default Letters --- - @Test - fun testLetters_oneWrongAnswer_summaryCorrect() { - val tapped = listOf("E") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Type X (Letters alias) --- - @Test - fun testTypeX_oneWrongAnswer_summaryCorrect() { - val tapped = listOf("a") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Type X Letters Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Words --- - @Test - fun testWords_threeWrongAnswers_summaryCorrect() { - val tapped = listOf("tob", "lig", "pum") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allWords, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Words Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allWords.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allWords.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Reading --- - @Test - fun testReading_threeWrongAnswers_summaryCorrect() { - val tapped = listOf("Lorem", "ipsum", "dolor") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allReading, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Reading Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allReading.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allReading.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "20") - } - - // --- Numbers --- - @Test - fun testNumbers_threeWrongAnswers_summaryCorrect() { - val tapped = listOf("1", "2", "3") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allNumbers, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Numbers Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allNumbers.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allNumbers.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "false") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Arithmetic --- - @Test - fun testArithmetic_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("1 + 2 = 3", "5 + 5 = 10") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allArithmetic, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Arithmetic Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allArithmetic.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allArithmetic.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "True") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Finish=2 --- - @Test - fun testLettersFinish2_oneWrongAnswer_summaryCorrect() { - val tapped = listOf("E") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Finish 2") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .assertEarlyFinishDialogAndConfirm() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Finish=1 --- - @Test - fun testLettersFinish1_twoWrongAnswers_lastAttemptedItem_summaryCorrect() { - val tapped = listOf("a", "w") - val lastAttemptedItem = "I" - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet(), lastAttemptedItem) - val expectedNotAttempted = TimedGridHelpers.notAttemptedItems(allLetters, lastAttemptedItem) - val totalAttempted = allLetters.size - expectedNotAttempted.size - val correctItems = totalAttempted - tapped.size - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Finish 1") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .assertEarlyFinishDialogAndConfirm() - .assertLastAttemptedItemDialogAndConfirm(lastAttemptedItem) - .clickFinishTestButton() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", totalAttempted.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", correctItems.toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", expectedNotAttempted.joinToString(", ")) - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters No Pause --- - @Test - fun testLettersNoPause_oneWrongAnswer_summaryCorrect() { - val tapped = listOf("a") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters No Pause") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .also { - // Capture time before - val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) - val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) - - it.clickOnId(org.odk.collect.timedgrid.R.id.button_timer) - timer.wait(2) - - // Capture time after - val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) - val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) - - // Assert that the time has decreased - assert(timeAfter < timeBefore) { - "Timer did not continue after clicking Timer button. Before=$timeBefore After=$timeAfter" - } - } - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "53") // 60 - 5 - 2 - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Strict True + Duration=10 --- - @Test - fun testLettersStrictTrueDuration10_oneWrongAnswer_lastAttemptedItem_summaryCorrect() { - val tapped = listOf("a") - val lastAttemptedItem = "w" - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet(), lastAttemptedItem) - val expectedNotAttempted = TimedGridHelpers.notAttemptedItems(allLetters, lastAttemptedItem) - val totalAttempted = allLetters.size - expectedNotAttempted.size - val correctItems = totalAttempted - tapped.size - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Strict True Duration 10") - .clickStartTestButton() - .also { timer.wait(2) } - .selectTestAnswers(tapped) - .also { timer.wait(10) } - .assertLastAttemptedItemDialogAndConfirm(lastAttemptedItem) - .clickFinishTestButton() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "0") - .assertAnswer("Total number of items attempted", totalAttempted.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", correctItems.toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", expectedNotAttempted.joinToString(", ")) - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Strict False + Duration=10 --- - @Test - fun testLettersStrictFalseDuration10_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("a", "w") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Strict False Duration 10") - .clickStartTestButton() - .also { timer.wait(2) } - .selectTestAnswers(tapped) - .also { timer.wait(10) } - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "0") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Strict True + EndAfter=2 --- - @Test - fun testLettersStrictTrueEndAfter2_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Strict True End After 2") - .clickStartTestButton() - .selectTestAnswers(tapped) - .assertTestEndedEarlyDialogAndConfirm(2) - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", ((timer.getMillisRemaining() / 1000.0).roundToInt()).toString()) - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Strict False + EndAfter=2 --- - @Test - fun testLettersStrictFalseEndAfter2_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Strict False End After 2") - .clickStartTestButton() - .selectTestAnswers(tapped) - .assertConsecutiveMistakesDialogAndContinue(2) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", ((timer.getMillisRemaining() / 1000.0).roundToInt()).toString()) - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters PageRows=2 --- - @Test - fun testLettersPageRows2_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Page Rows 2") - .clickStartTestButton() - .also { TimedGridHelpers.assertVisibleRows(2) } - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Grid + Summary --- - @Test - fun testLettersGridAndSummary_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnGroup("Letters Grid And Summary:") - .clickOnQuestion("Letters Grid And Summary") - // Assert summary fields are visible immediately - .assertTexts("Amount of time remaining in seconds", "Total number of items attempted", "Number of incorrect items", - "Number of correct items", "Whether the firstline was all incorrect", "The list of correct items", - "The list of items not attempted/answered", "The total number of punctuation marks") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Grid + Summary (Relevant / Group 2) --- - @Test - fun testLettersGridAndSummary2_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnGroup("Letters Grid And Summary + Relevant:") - .clickOnQuestion("Letters Grid And Summary Relevant") - // Assert summary fields are not visible yet - .assertTextsDoNotExist("Amount of time remaining in seconds", "Total number of items attempted", "Number of incorrect items", - "Number of correct items", "Whether the firstline was all incorrect", "The list of correct items", - "The list of items not attempted/answered", "The total number of punctuation marks") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .assertAnswer("Amount of time remaining in seconds", "55") - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .assertAnswer("Whether the firstline was all incorrect", "False") - .assertAnswer("The list of correct items", expectedCorrect) - .assertAnswer("The list of items not attempted/answered", "") - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Letters Grid No Group --- - @Test - fun testLettersNoGroup_twoWrongAnswers_summaryCorrect() { - val tapped = listOf("L", "i") - val expectedCorrect = TimedGridHelpers.expectedCorrectItems(allLetters, tapped.toSet()) - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters No Group") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .clickUntilEarlyFinish() - .clickForwardButton() - .assertAnswer("Amount of time remaining in seconds", "55") - .clickForwardButton() - .assertAnswer("Total number of items attempted", allLetters.size.toString()) - .clickForwardButton() - .assertAnswer("Number of incorrect items", tapped.size.toString()) - .clickForwardButton() - .assertAnswer("Number of correct items", (allLetters.size - tapped.size).toString()) - .clickForwardButton() - .assertAnswer("Whether the firstline was all incorrect", "False") - .clickForwardButton() - .assertAnswer("The number of sentence end marks (e.g. periods) passed, as indicated by the last attempted item when using the oral reading test type", "0") - .clickForwardButton() - .assertAnswer("The list of correct items", expectedCorrect) - .clickForwardButton() - .assertAnswer("The list of items not attempted/answered", "") - .clickForwardButton() - .assertAnswer("The total number of punctuation marks", "0") - } - - // --- Check If Timer Works When Rotating The Device --- - @Test - fun checkTimerWhenRotating_timerContinuesRunning() { - val tapped = listOf("a", "w") - - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Test") - .clickStartTestButton() - .also { timer.wait(5) } - .selectTestAnswers(tapped) - .also { - // Capture timer before rotation - val timeBeforeText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) - val timeBefore = TimedGridHelpers.extractTimeLeft(timeBeforeText) - - // Rotate device - it.rotateToLandscape(it) - timer.wait(5) - - // Capture timer after rotation - val timeAfterText = TimedGridHelpers.getTextFromView(org.odk.collect.timedgrid.R.id.button_timer) - val timeAfter = TimedGridHelpers.extractTimeLeft(timeAfterText) - - // Assert timer is still running - assert(timeAfter < timeBefore) { - "Timer did not continue after rotation. Before=$timeBefore After=$timeAfter" - } - } - } - - @Test - fun blockNavigationWhileTestRunning() { - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Letters Test") - .clickStartTestButton() - .clickForwardButtonWithError() - .clickGoToArrowWithError() - .clickOptionsIcon() - .clickProjectSettingsWithError() - .clickPauseTestButton() - .clickForwardButtonWithError() - .clickGoToArrowWithError() - .clickOptionsIcon() - .clickProjectSettingsWithError() - } - - @Test - fun testTranslationsAreApplied() { - rule.startAtMainMenu() - .copyForm("timed-grid-form.xml") - .startBlankForm("timed-grid-form") - .clickGoToArrow() - .clickOnQuestion("Words Test") - // Assert original (English) labels - .assertTexts(*allWords.take(3).toTypedArray()) - // Change language - .clickOptionsIcon() - .clickOnString(org.odk.collect.strings.R.string.change_language) - .clickOnText("Polish (pl)") - // Assert translated labels - .assertTexts(*allWordsPl.take(3).toTypedArray()) - } - - companion object { - private val allLetters = listOf( - "L", "i", "h", "R", "S", "y", "E", "O", "n", "T", - "i", "e", "T", "D", "A", "t", "a", "d", "e", "w", - "h", "O", "e", "m", "U", "r", "L", "G", "R", "u", - "G", "R", "B", "E", "i", "f", "m", "t", "s", "r", - "S", "T", "C", "N", "p", "A", "F", "c", "G", "E", - "y", "Q", "A", "M", "C", "O", "t", "n", "P", "s", - "e", "A", "b", "s", "O", "F", "h", "u", "A", "t", - "R", "z", "H", "e", "S", "i", "g", "m", "i", "L", - "o", "I", "N", "O", "e", "L", "E", "r", "p", "X", - "H", "A", "c", "D", "d", "t", "O", "j", "e", "n" - ) - - private val allWords = listOf( - "tob", "lig", "pum", "inbok", "maton", - "gatch", "tup", "noom", "sen", "timming", - "beeth", "mun", "ellus", "fot", "widge", - "han", "pite", "dazz", "unstade", "rike", - "fipper", "chack", "gub", "weem", "foin", - "ithan", "feth", "tade", "anth", "bom", - "ruck", "rax", "jad", "foob", "bapent", - "sull", "lotch", "snim", "queet", "reb", - "lunkest", "vown", "coll", "kittle", "moy", - "div", "trinless", "pran", "nauk", "otta" - ) - - private val allWordsPl = listOf( - "tob (pl)", "lig (pl)", "pum (pl)", "inbok (pl)", "maton (pl)", - "gatch (pl)", "tup (pl)", "noom (pl)", "sen (pl)", "timming (pl)", - "beeth (pl)", "mun (pl)", "ellus (pl)", "fot (pl)", "widge (pl)", - "han (pl)", "pite (pl)", "dazz (pl)", "unstade (pl)", "rike (pl)", - "fipper (pl)", "chack (pl)", "gub (pl)", "weem (pl)", "foin (pl)", - "ithan (pl)", "feth (pl)", "tade (pl)", "anth (pl)", "bom (pl)", - "ruck (pl)", "rax (pl)", "jad (pl)", "foob (pl)", "bapent (pl)", - "sull (pl)", "lotch (pl)", "snim (pl)", "queet (pl)", "reb (pl)", - "lunkest (pl)", "vown (pl)", "coll (pl)", "kittle (pl)", "moy (pl)", - "div (pl)", "trinless (pl)", "pran (pl)", "nauk (pl)", "otta (pl)" - ) - - private val allReading = listOf( - "Lorem", "ipsum", "dolor", "sit", "amet", ",", - "consectetur", "adipiscing", "elit", ".", - "Phasellus", "vel", "tortor", "neque", ".", - "Nulla", "vestibulum", "dictum", "nibh", ",", - "eu", "vehicula", "felis", ".", "Suspendisse", - "condimentum", "turpis", "ac", "viverra", "fermentum", - ".", "Ut", "tincidunt", "metus", "a", "ante", - "rhoncus", "suscipit", ".", "Ut", "sed", "lacus", - "egestas", ",", "aliquam", "urna", "eu", "sollicitudin", - "risus", ".", "Vestibulum", "imperdiet", "bibendum", - "imperdiet", ".", "Quisque", "vitae", "felis", "tellus", - ".", "Vivamus", "sit", "amet", "consectetur", "diam", - ",", "eget", "auctor", "ligula", ".", "Phasellus", - "vestibulum", ",", "ante", "id", "pharetra", "iaculis", - ",", "mi", "nibh", "tristique", "urna", ",", "in", - "lacinia", "arcu", "risus", "eu", "urna.", ".", - "Aenean", "sollicitudin", "elementum", "erat", "vel", - "feugiat", ".", "Nullam", "venenatis", "mattis", "metus", - ",", "vel", "fringilla", "nunc", "pulvinar", "a", "." - ) - - private val allNumbers = (1..50).map { it.toString() } - - private val allArithmetic = listOf( - "1 + 2 = 3", "5 + 5 = 10", "7 + 1 = 8", "4 + 3 = 7", "6 + 0 = 6", - "5 - 2 = 3", "8 - 4 = 4", "10 - 1 = 9", "9 - 6 = 3", "7 - 7 = 0", - "2 x 3 = 6", "4 x 2 = 8", "5 x 4 = 20", "3 x 3 = 9", "1 x 8 = 8", - "10 ÷ 2 = 5", "9 ÷ 3 = 3", "8 ÷ 4 = 2", "6 ÷ 1 = 6", "12 ÷ 6 = 2" - ) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e213c94283..5c374771848 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,6 @@ glide = "5.0.5" camerax = "1.4.2" # Newer versions require minSdkVersion >= 23 espresso = "3.7.0" kotlin = "2.2.20" -junit = "1.3.0" [libraries] firebaseCrashlyticsGradle = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version = "3.0.6" } @@ -118,7 +117,6 @@ okhttp3Mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", hamcrest = { group = "org.hamcrest", name = "hamcrest", version = "3.0" } robolectric = { group = "org.robolectric", name = "robolectric", version = "4.16" } uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" } -ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } [plugins] androidApplication = { id = "com.android.application" } diff --git a/test-forms/src/main/resources/forms/timed-grid-form.xml b/test-forms/src/main/resources/forms/timed-grid-form.xml deleted file mode 100644 index b9424b5cfc5..00000000000 --- a/test-forms/src/main/resources/forms/timed-grid-form.xml +++ /dev/null @@ -1,4401 +0,0 @@ - - - - timed-grid-form - - - - - L - - - i - - - h - - - R - - - S - - - y - - - E - - - O - - - n - - - T - - - i - - - e - - - T - - - D - - - A - - - t - - - a - - - d - - - e - - - w - - - h - - - O - - - e - - - m - - - U - - - r - - - L - - - G - - - R - - - u - - - G - - - R - - - B - - - E - - - i - - - f - - - m - - - t - - - s - - - r - - - S - - - T - - - C - - - N - - - p - - - A - - - F - - - c - - - G - - - E - - - y - - - Q - - - A - - - M - - - C - - - O - - - t - - - n - - - P - - - s - - - e - - - A - - - b - - - s - - - O - - - F - - - h - - - u - - - A - - - t - - - R - - - z - - - H - - - e - - - S - - - i - - - g - - - m - - - i - - - L - - - o - - - I - - - N - - - O - - - e - - - L - - - E - - - r - - - p - - - X - - - H - - - A - - - c - - - D - - - d - - - t - - - O - - - j - - - e - - - n - - - All answered - - - tob - - - lig - - - pum - - - inbok - - - maton - - - gatch - - - tup - - - noom - - - sen - - - timming - - - beeth - - - mun - - - ellus - - - fot - - - widge - - - han - - - pite - - - dazz - - - unstade - - - rike - - - fipper - - - chack - - - gub - - - weem - - - foin - - - ithan - - - feth - - - tade - - - anth - - - bom - - - ruck - - - rax - - - jad - - - foob - - - bapent - - - sull - - - lotch - - - snim - - - queet - - - reb - - - lunkest - - - vown - - - coll - - - kittle - - - moy - - - div - - - trinless - - - pran - - - nauk - - - otta - - - All answered - - - Lorem - - - ipsum - - - dolor - - - sit - - - amet - - - , - - - consectetur - - - adipiscing - - - elit - - - . - - - Phasellus - - - vel - - - tortor - - - neque - - - . - - - Nulla - - - vestibulum - - - dictum - - - nibh - - - , - - - eu - - - vehicula - - - felis - - - . - - - Suspendisse - - - condimentum - - - turpis - - - ac - - - viverra - - - fermentum - - - . - - - Ut - - - tincidunt - - - metus - - - a - - - ante - - - rhoncus - - - suscipit - - - . - - - Ut - - - sed - - - lacus - - - egestas - - - , - - - aliquam - - - urna - - - eu - - - sollicitudin - - - risus - - - . - - - Vestibulum - - - imperdiet - - - bibendum - - - imperdiet - - - . - - - Quisque - - - vitae - - - felis - - - tellus - - - . - - - Vivamus - - - sit - - - amet - - - consectetur - - - diam - - - , - - - eget - - - auctor - - - ligula - - - . - - - Phasellus - - - vestibulum - - - , - - - ante - - - id - - - pharetra - - - iaculis - - - , - - - mi - - - nibh - - - tristique - - - urna - - - , - - - in - - - lacinia - - - arcu - - - risus - - - eu - - - urna. - - - . - - - Aenean - - - sollicitudin - - - elementum - - - erat - - - vel - - - feugiat - - - . - - - Nullam - - - venenatis - - - mattis - - - metus - - - , - - - vel - - - fringilla - - - nunc - - - pulvinar - - - a - - - . - - - All answered - - - 1 - - - 2 - - - 3 - - - 4 - - - 5 - - - 6 - - - 7 - - - 8 - - - 9 - - - 10 - - - 11 - - - 12 - - - 13 - - - 14 - - - 15 - - - 16 - - - 17 - - - 18 - - - 19 - - - 20 - - - 21 - - - 22 - - - 23 - - - 24 - - - 25 - - - 26 - - - 27 - - - 28 - - - 29 - - - 30 - - - 31 - - - 32 - - - 33 - - - 34 - - - 35 - - - 36 - - - 37 - - - 38 - - - 39 - - - 40 - - - 41 - - - 42 - - - 43 - - - 44 - - - 45 - - - 46 - - - 47 - - - 48 - - - 49 - - - 50 - - - All answered - - - 1 + 2 = 3 - - - 5 + 5 = 10 - - - 7 + 1 = 8 - - - 4 + 3 = 7 - - - 6 + 0 = 6 - - - 5 - 2 = 3 - - - 8 - 4 = 4 - - - 10 - 1 = 9 - - - 9 - 6 = 3 - - - 7 - 7 = 0 - - - 2 x 3 = 6 - - - 4 x 2 = 8 - - - 5 x 4 = 20 - - - 3 x 3 = 9 - - - 1 x 8 = 8 - - - 10 ÷ 2 = 5 - - - 9 ÷ 3 = 3 - - - 8 ÷ 4 = 2 - - - 6 ÷ 1 = 6 - - - 12 ÷ 6 = 2 - - - All answered - - - - - tob (pl) - - - lig (pl) - - - pum (pl) - - - inbok (pl) - - - maton (pl) - - - gatch (pl) - - - tup (pl) - - - noom (pl) - - - sen (pl) - - - timming (pl) - - - beeth (pl) - - - mun (pl) - - - ellus (pl) - - - fot (pl) - - - widge (pl) - - - han (pl) - - - pite (pl) - - - dazz (pl) - - - unstade (pl) - - - rike (pl) - - - fipper (pl) - - - chack (pl) - - - gub (pl) - - - weem (pl) - - - foin (pl) - - - ithan (pl) - - - feth (pl) - - - tade (pl) - - - anth (pl) - - - bom (pl) - - - ruck (pl) - - - rax (pl) - - - jad (pl) - - - foob (pl) - - - bapent (pl) - - - sull (pl) - - - lotch (pl) - - - snim (pl) - - - queet (pl) - - - reb (pl) - - - lunkest (pl) - - - vown (pl) - - - coll (pl) - - - kittle (pl) - - - moy (pl) - - - div (pl) - - - trinless (pl) - - - pran (pl) - - - nauk (pl) - - - otta (pl) - - - Wszystkie odpowiedzi poprawne - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - letters-0 - 1 - - - letters-1 - 2 - - - letters-2 - 3 - - - letters-3 - 4 - - - letters-4 - 5 - - - letters-5 - 6 - - - letters-6 - 7 - - - letters-7 - 8 - - - letters-8 - 9 - - - letters-9 - 10 - - - letters-10 - 11 - - - letters-11 - 12 - - - letters-12 - 13 - - - letters-13 - 14 - - - letters-14 - 15 - - - letters-15 - 16 - - - letters-16 - 17 - - - letters-17 - 18 - - - letters-18 - 19 - - - letters-19 - 20 - - - letters-20 - 21 - - - letters-21 - 22 - - - letters-22 - 23 - - - letters-23 - 24 - - - letters-24 - 25 - - - letters-25 - 26 - - - letters-26 - 27 - - - letters-27 - 28 - - - letters-28 - 29 - - - letters-29 - 30 - - - letters-30 - 31 - - - letters-31 - 32 - - - letters-32 - 33 - - - letters-33 - 34 - - - letters-34 - 35 - - - letters-35 - 36 - - - letters-36 - 37 - - - letters-37 - 38 - - - letters-38 - 39 - - - letters-39 - 40 - - - letters-40 - 41 - - - letters-41 - 42 - - - letters-42 - 43 - - - letters-43 - 44 - - - letters-44 - 45 - - - letters-45 - 46 - - - letters-46 - 47 - - - letters-47 - 48 - - - letters-48 - 49 - - - letters-49 - 50 - - - letters-50 - 51 - - - letters-51 - 52 - - - letters-52 - 53 - - - letters-53 - 54 - - - letters-54 - 55 - - - letters-55 - 56 - - - letters-56 - 57 - - - letters-57 - 58 - - - letters-58 - 59 - - - letters-59 - 60 - - - letters-60 - 61 - - - letters-61 - 62 - - - letters-62 - 63 - - - letters-63 - 64 - - - letters-64 - 65 - - - letters-65 - 66 - - - letters-66 - 67 - - - letters-67 - 68 - - - letters-68 - 69 - - - letters-69 - 70 - - - letters-70 - 71 - - - letters-71 - 72 - - - letters-72 - 73 - - - letters-73 - 74 - - - letters-74 - 75 - - - letters-75 - 76 - - - letters-76 - 77 - - - letters-77 - 78 - - - letters-78 - 79 - - - letters-79 - 80 - - - letters-80 - 81 - - - letters-81 - 82 - - - letters-82 - 83 - - - letters-83 - 84 - - - letters-84 - 85 - - - letters-85 - 86 - - - letters-86 - 87 - - - letters-87 - 88 - - - letters-88 - 89 - - - letters-89 - 90 - - - letters-90 - 91 - - - letters-91 - 92 - - - letters-92 - 93 - - - letters-93 - 94 - - - letters-94 - 95 - - - letters-95 - 96 - - - letters-96 - 97 - - - letters-97 - 98 - - - letters-98 - 99 - - - letters-99 - 100 - - - letters-100 - 999 - - - - - - - words-0 - 1 - - - words-1 - 2 - - - words-2 - 3 - - - words-3 - 4 - - - words-4 - 5 - - - words-5 - 6 - - - words-6 - 7 - - - words-7 - 8 - - - words-8 - 9 - - - words-9 - 10 - - - words-10 - 11 - - - words-11 - 12 - - - words-12 - 13 - - - words-13 - 14 - - - words-14 - 15 - - - words-15 - 16 - - - words-16 - 17 - - - words-17 - 18 - - - words-18 - 19 - - - words-19 - 20 - - - words-20 - 21 - - - words-21 - 22 - - - words-22 - 23 - - - words-23 - 24 - - - words-24 - 25 - - - words-25 - 26 - - - words-26 - 27 - - - words-27 - 28 - - - words-28 - 29 - - - words-29 - 30 - - - words-30 - 31 - - - words-31 - 32 - - - words-32 - 33 - - - words-33 - 34 - - - words-34 - 35 - - - words-35 - 36 - - - words-36 - 37 - - - words-37 - 38 - - - words-38 - 39 - - - words-39 - 40 - - - words-40 - 41 - - - words-41 - 42 - - - words-42 - 43 - - - words-43 - 44 - - - words-44 - 45 - - - words-45 - 46 - - - words-46 - 47 - - - words-47 - 48 - - - words-48 - 49 - - - words-49 - 50 - - - words-50 - 999 - - - - - - - reading-0 - 1 - - - reading-1 - 2 - - - reading-2 - 3 - - - reading-3 - 4 - - - reading-4 - 5 - - - reading-5 - 6 - - - reading-6 - 7 - - - reading-7 - 8 - - - reading-8 - 9 - - - reading-9 - 10 - - - reading-10 - 11 - - - reading-11 - 12 - - - reading-12 - 13 - - - reading-13 - 14 - - - reading-14 - 15 - - - reading-15 - 16 - - - reading-16 - 17 - - - reading-17 - 18 - - - reading-18 - 19 - - - reading-19 - 20 - - - reading-20 - 21 - - - reading-21 - 22 - - - reading-22 - 23 - - - reading-23 - 24 - - - reading-24 - 25 - - - reading-25 - 26 - - - reading-26 - 27 - - - reading-27 - 28 - - - reading-28 - 29 - - - reading-29 - 30 - - - reading-30 - 31 - - - reading-31 - 32 - - - reading-32 - 33 - - - reading-33 - 34 - - - reading-34 - 35 - - - reading-35 - 36 - - - reading-36 - 37 - - - reading-37 - 38 - - - reading-38 - 39 - - - reading-39 - 40 - - - reading-40 - 41 - - - reading-41 - 42 - - - reading-42 - 43 - - - reading-43 - 44 - - - reading-44 - 45 - - - reading-45 - 46 - - - reading-46 - 47 - - - reading-47 - 48 - - - reading-48 - 49 - - - reading-49 - 50 - - - reading-50 - 51 - - - reading-51 - 52 - - - reading-52 - 53 - - - reading-53 - 54 - - - reading-54 - 55 - - - reading-55 - 56 - - - reading-56 - 57 - - - reading-57 - 58 - - - reading-58 - 59 - - - reading-59 - 60 - - - reading-60 - 61 - - - reading-61 - 62 - - - reading-62 - 63 - - - reading-63 - 64 - - - reading-64 - 65 - - - reading-65 - 66 - - - reading-66 - 67 - - - reading-67 - 68 - - - reading-68 - 69 - - - reading-69 - 70 - - - reading-70 - 71 - - - reading-71 - 72 - - - reading-72 - 73 - - - reading-73 - 74 - - - reading-74 - 75 - - - reading-75 - 76 - - - reading-76 - 77 - - - reading-77 - 78 - - - reading-78 - 79 - - - reading-79 - 80 - - - reading-80 - 81 - - - reading-81 - 82 - - - reading-82 - 83 - - - reading-83 - 84 - - - reading-84 - 85 - - - reading-85 - 86 - - - reading-86 - 87 - - - reading-87 - 88 - - - reading-88 - 89 - - - reading-89 - 90 - - - reading-90 - 91 - - - reading-91 - 92 - - - reading-92 - 93 - - - reading-93 - 94 - - - reading-94 - 95 - - - reading-95 - 96 - - - reading-96 - 97 - - - reading-97 - 98 - - - reading-98 - 99 - - - reading-99 - 100 - - - reading-100 - 101 - - - reading-101 - 102 - - - reading-102 - 103 - - - reading-103 - 104 - - - reading-104 - 105 - - - reading-105 - 106 - - - reading-106 - 107 - - - reading-107 - 108 - - - reading-108 - 999 - - - - - - - numbers-0 - 1 - - - numbers-1 - 2 - - - numbers-2 - 3 - - - numbers-3 - 4 - - - numbers-4 - 5 - - - numbers-5 - 6 - - - numbers-6 - 7 - - - numbers-7 - 8 - - - numbers-8 - 9 - - - numbers-9 - 10 - - - numbers-10 - 11 - - - numbers-11 - 12 - - - numbers-12 - 13 - - - numbers-13 - 14 - - - numbers-14 - 15 - - - numbers-15 - 16 - - - numbers-16 - 17 - - - numbers-17 - 18 - - - numbers-18 - 19 - - - numbers-19 - 20 - - - numbers-20 - 21 - - - numbers-21 - 22 - - - numbers-22 - 23 - - - numbers-23 - 24 - - - numbers-24 - 25 - - - numbers-25 - 26 - - - numbers-26 - 27 - - - numbers-27 - 28 - - - numbers-28 - 29 - - - numbers-29 - 30 - - - numbers-30 - 31 - - - numbers-31 - 32 - - - numbers-32 - 33 - - - numbers-33 - 34 - - - numbers-34 - 35 - - - numbers-35 - 36 - - - numbers-36 - 37 - - - numbers-37 - 38 - - - numbers-38 - 39 - - - numbers-39 - 40 - - - numbers-40 - 41 - - - numbers-41 - 42 - - - numbers-42 - 43 - - - numbers-43 - 44 - - - numbers-44 - 45 - - - numbers-45 - 46 - - - numbers-46 - 47 - - - numbers-47 - 48 - - - numbers-48 - 49 - - - numbers-49 - 50 - - - numbers-50 - 999 - - - - - - - arithmetic-0 - 1 - - - arithmetic-1 - 2 - - - arithmetic-2 - 3 - - - arithmetic-3 - 4 - - - arithmetic-4 - 5 - - - arithmetic-5 - 6 - - - arithmetic-6 - 7 - - - arithmetic-7 - 8 - - - arithmetic-8 - 9 - - - arithmetic-9 - 10 - - - arithmetic-10 - 11 - - - arithmetic-11 - 12 - - - arithmetic-12 - 13 - - - arithmetic-13 - 14 - - - arithmetic-14 - 15 - - - arithmetic-15 - 16 - - - arithmetic-16 - 17 - - - arithmetic-17 - 18 - - - arithmetic-18 - 19 - - - arithmetic-19 - 20 - - - arithmetic-20 - 999 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt index 28b45ff51fb..08e1e5dd5e0 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/PausableCountDownTimer.kt @@ -2,7 +2,7 @@ package org.odk.collect.timedgrid import android.os.CountDownTimer -class PausableCountDownTimer : Timer { +class PausableCountDownTimer { private var millisRemaining: Long = 0 private lateinit var onTick: (millisUntilFinished: Long) -> Unit private lateinit var onFinish: () -> Unit @@ -10,12 +10,12 @@ class PausableCountDownTimer : Timer { private var timer: CountDownTimer? = null private var isPaused: Boolean = true - override fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { + fun setUpListeners(onTick: (millisUntilFinished: Long) -> Unit, onFinish: () -> Unit) { this.onTick = onTick this.onFinish = onFinish } - override fun setUpDuration(millisRemaining: Long) { + fun setUpDuration(millisRemaining: Long) { this.millisRemaining = millisRemaining } @@ -24,7 +24,7 @@ class PausableCountDownTimer : Timer { * @return This PausableCountDownTimer. */ @Synchronized - override fun start(): Timer { + fun start(): PausableCountDownTimer { if (isPaused) { isPaused = false timer = object : CountDownTimer(millisRemaining, 1000) { @@ -46,7 +46,7 @@ class PausableCountDownTimer : Timer { * Pauses the countdown. */ @Synchronized - override fun pause() { + fun pause() { if (!isPaused) { timer?.cancel() timer = null @@ -58,13 +58,13 @@ class PausableCountDownTimer : Timer { * Cancels the countdown and resets the timer. */ @Synchronized - override fun cancel() { + fun cancel() { timer?.cancel() timer = null isPaused = true } - override fun getMillisRemaining(): Long { + fun getMillisRemaining(): Long { return millisRemaining } } diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt index 8abb4aa43f5..b7ffc760496 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt @@ -25,7 +25,7 @@ class TimedGridWidgetDelegate( private val widgetValueChanged: () -> Unit ) { private val viewModel = ViewModelProvider(context as ViewModelStoreOwner)[TimedGridViewModel::class.java] - private val timer: Timer = TimerProvider.get() + private val timer = PausableCountDownTimer() // Parsed prompt configuration (type, duration, etc.). private val config = TimedGridWidgetConfiguration.fromPrompt( diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt deleted file mode 100644 index 66cf025235e..00000000000 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/Timer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.odk.collect.timedgrid - -interface Timer { - fun setUpListeners( - onTick: (millisUntilFinished: Long) -> Unit, - onFinish: () -> Unit - ) - - fun setUpDuration(millisRemaining: Long) - - fun start(): Timer - - fun pause() - - fun cancel() - - fun getMillisRemaining(): Long -} - -object TimerProvider { - var factory: () -> Timer = { PausableCountDownTimer() } - - fun get(): Timer = factory() -} From 8dacc0dba2d0998ef8969f4253b0fb98940247bc Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Fri, 27 Feb 2026 11:03:55 +0100 Subject: [PATCH 12/14] Siplify NavigationAwareWidget interface --- .../collect/androidshared/ui/DialogUtils.kt | 20 ++++++++++++++++ .../activities/FormFillingActivity.java | 17 ++++++++----- .../collect/android/formentry/ODKView.java | 24 +++++++------------ .../android/widgets/TimedGridWidget.kt | 7 ++---- .../timedgrid/NavigationAwareWidget.kt | 15 ++++++------ .../OngoingAssessmentWarningDialogFragment.kt | 17 ------------- .../timedgrid/TimedGridWidgetDelegate.kt | 14 +++++++++-- 7 files changed, 62 insertions(+), 52 deletions(-) create mode 100644 androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogUtils.kt delete mode 100644 timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt 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/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java index 7b77cf8502a..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,8 +390,9 @@ public void allowSwiping(boolean doSwipe) { private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (odkView != null && odkView.isNavigationBlocked()) { - DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + 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()); @@ -500,8 +503,9 @@ public void save() { } }, () -> { - if (odkView != null && odkView.isNavigationBlocked()) { - DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + NavigationWarning navigationWarning = odkView != null ? odkView.isNavigationBlocked() : null; + if (navigationWarning != null) { + DialogUtils.show(this, navigationWarning.getTitleRes(), navigationWarning.getMessageRes()); swipeHandler.setBeenSwiped(false); return false; } @@ -1213,8 +1217,9 @@ private void moveScreen(Direction direction) { return; } - if (odkView != null && odkView.isNavigationBlocked()) { - DialogFragmentUtils.showIfNotShowing(odkView.getFirstNavigationBlockedWarningDialog().get(), getSupportFragmentManager()); + NavigationWarning navigationWarning = odkView != null ? odkView.isNavigationBlocked() : null; + if (navigationWarning != null) { + DialogUtils.show(this, navigationWarning.getTitleRes(), navigationWarning.getMessageRes()); swipeHandler.setBeenSwiped(false); return; } 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 f62a2e95131..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 @@ -41,7 +41,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.widget.NestedScrollView; -import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; @@ -91,6 +90,7 @@ 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; @@ -99,7 +99,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Objects; import java.util.Set; import javax.inject.Inject; @@ -808,20 +808,14 @@ private void updateQuestions(FormEntryPrompt[] prompts) { } } - public boolean isNavigationBlocked() { - return getFirstNavigationBlockingWidget().isPresent(); - } - - public Optional> getFirstNavigationBlockedWarningDialog() { - return getFirstNavigationBlockingWidget().map(NavigationAwareWidget::getWarningDialog); - } - - private Optional getFirstNavigationBlockingWidget() { - return widgets - .stream() + @Nullable + public NavigationWarning isNavigationBlocked() { + return widgets.stream() .filter(widget -> widget instanceof NavigationAwareWidget) .map(widget -> (NavigationAwareWidget) widget) - .filter(NavigationAwareWidget::shouldBlockNavigation) - .findFirst(); + .map(NavigationAwareWidget::shouldBlockNavigation) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } } 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 index 8aba56ee36c..de4550911de 100644 --- 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 @@ -2,7 +2,6 @@ package org.odk.collect.android.widgets import android.annotation.SuppressLint import android.content.Context -import androidx.fragment.app.DialogFragment import org.javarosa.core.model.FormIndex import org.javarosa.core.model.IFormElement import org.javarosa.core.model.data.IAnswerData @@ -14,7 +13,7 @@ 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.OngoingAssessmentWarningDialogFragment +import org.odk.collect.timedgrid.NavigationWarning import org.odk.collect.timedgrid.TimedGridWidgetDelegate @SuppressLint("ViewConstructor") @@ -79,7 +78,5 @@ class TimedGridWidget( widgetDelegate.onDetachedFromWindow() } - override fun shouldBlockNavigation(): Boolean = widgetDelegate.shouldBlockNavigation() - - override fun getWarningDialog(): Class = OngoingAssessmentWarningDialogFragment::class.java + override fun shouldBlockNavigation(): NavigationWarning? = widgetDelegate.shouldBlockNavigation() } diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt index fcf93d9a522..50b514dfff8 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/NavigationAwareWidget.kt @@ -1,15 +1,16 @@ package org.odk.collect.timedgrid -import androidx.fragment.app.DialogFragment +import androidx.annotation.StringRes interface NavigationAwareWidget { - /** - * Whenever the navigation should be stopped. - */ - fun shouldBlockNavigation(): Boolean /** - * If navigation should be stopped (see #shouldBlockNavigation) this returns DialogFragment to present to user. + * Returns NavigationBlock if navigation should be blocked, or null if navigation is allowed. */ - fun getWarningDialog(): Class + 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/OngoingAssessmentWarningDialogFragment.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt deleted file mode 100644 index 70cebfcf4fb..00000000000 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/OngoingAssessmentWarningDialogFragment.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.odk.collect.timedgrid - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class OngoingAssessmentWarningDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.assessment) - .setMessage(R.string.assessment_warning) - .setPositiveButton(org.odk.collect.strings.R.string.ok, null) - .create() - } -} diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt index b7ffc760496..3978509627c 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt @@ -274,8 +274,18 @@ class TimedGridWidgetDelegate( ) } - fun shouldBlockNavigation(): Boolean = - state == TimedGridState.IN_PROGRESS || + 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 + } + } } From f4af72976e6cee640c892b28c5a12a0aa719b02b Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Fri, 27 Feb 2026 11:59:57 +0100 Subject: [PATCH 13/14] Update appearance --- .../main/java/org/odk/collect/android/utilities/Appearances.kt | 2 +- .../java/org/odk/collect/android/widgets/WidgetFactory.java | 2 +- .../org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 ff73a9d2843..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,7 +91,7 @@ object Appearances { const val MASKED = "masked" const val COUNTER = "counter" const val MULTILINE = "multiline" - const val TIMED_GRID = "timed-grid" + 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/WidgetFactory.java b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java index b11bd09ca3c..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,7 +265,7 @@ 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.TIMED_GRID)) { + } 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/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt index 7a0de8476dc..59ceaae2922 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -17,7 +17,7 @@ class TimedGridSummaryAnswerCreator( private val formAnswerRefresher: FormAnswerRefresher ) { companion object { - val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""timed-grid-answer\((.+),(.+)\)""") + val SUMMARY_QUESTION_APPEARANCE_REGEX = Regex("""x-timed-grid-answer\((.+),(.+)\)""") } fun answerSummaryQuestions(summary: TimedGridSummary) { From 7848c4322c26e64545ae4224371f19b3e2bbad5b Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Mon, 2 Mar 2026 01:54:43 +0100 Subject: [PATCH 14/14] Pass items via FormControllerFacade --- .../odk/collect/android/widgets/TimedGridWidget.kt | 12 +++++++----- .../timedgrid/TimedGridSummaryAnswerCreator.kt | 2 ++ .../odk/collect/timedgrid/TimedGridWidgetDelegate.kt | 3 +-- 3 files changed, 10 insertions(+), 7 deletions(-) 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 index de4550911de..4dacd9466ae 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -23,19 +24,20 @@ class TimedGridWidget( dependencies: Dependencies, formEntryViewModel: FormEntryViewModel ) : QuestionWidget(context, dependencies, questionDetails), NavigationAwareWidget { - private val items = ItemsWidgetUtils.loadItemsAndHandleErrors( - this, questionDetails.prompt, formEntryViewModel - ) - private val widgetDelegate = TimedGridWidgetDelegate( context, questionDetails.prompt, - items, 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) } diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt index 59ceaae2922..95f264eb257 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridSummaryAnswerCreator.kt @@ -4,6 +4,7 @@ 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 @@ -73,6 +74,7 @@ class TimedGridSummaryAnswerCreator( interface FormControllerFacade { fun getFormElements(): List? + fun getItems(): List fun saveAnswer(index: FormIndex, answer: IAnswerData) } diff --git a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt index 3978509627c..0f551a59741 100644 --- a/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt +++ b/timedgrid/src/main/java/org/odk/collect/timedgrid/TimedGridWidgetDelegate.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.javarosa.core.model.SelectChoice import org.javarosa.core.model.data.IAnswerData import org.javarosa.core.model.data.MultipleItemsData import org.javarosa.core.model.data.helper.Selection @@ -19,12 +18,12 @@ import kotlin.time.Duration.Companion.milliseconds class TimedGridWidgetDelegate( private val context: Context, private val formEntryPrompt: FormEntryPrompt, - private val items: List, 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.).