From 2367cdadd486462abab70f01df0e10e62179922e Mon Sep 17 00:00:00 2001 From: Nsubuga Date: Mon, 23 Feb 2026 14:54:55 +0300 Subject: [PATCH 01/13] Add a conditional to e2e test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CompleteAllTaskTypesTest didn't cover conditional logic. Added a conditional question to the test survey and added tests for it in order to prevent regressions Closes #3481 --- .../android/e2etest/TestConfig.kt | 7 +++++- .../android/e2etest/TestTask.kt | 1 + .../e2etest/drivers/AndroidTestDriver.kt | 24 +++++++++++++++++-- .../android/e2etest/drivers/TestDriver.kt | 2 +- .../e2etest/robots/DataCollectionRobot.kt | 5 ++-- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index 60b31f7554..3c40e0c761 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -33,7 +33,7 @@ object TestConfig { TestTask(taskType = Task.Type.DROP_PIN, isRequired = true), TestTask(Task.Type.INSTRUCTIONS), TestTask(Task.Type.TEXT), - TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = listOf(1)), + TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = listOf(1), isACondition = true), TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = (0..3).toList()), TestTask(Task.Type.NUMBER), TestTask(Task.Type.PHOTO), @@ -45,4 +45,9 @@ object TestConfig { val TEST_LIST_DRAW_AREA = listOf(TestTask(taskType = Task.Type.DRAW_AREA, isRequired = true)) const val LOI_NAME = "Test location" const val TEST_PHOTO_FILE = "e2e_test_photo.webp" + const val ARABICA_TEXT = "Arabica" + const val COFFEE_TEXT = "Coffee" + const val NEXT_BUTTON_TEXT = "Next" + const val PREVIOUS_BUTTON_TEXT = "Previous" + const val COVER_CROPPING_TEXT = "Cover Cropping" } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt index ffc3db15d4..d7281ce2db 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt @@ -21,4 +21,5 @@ data class TestTask( val taskType: Task.Type, val isRequired: Boolean = false, val selectIndexes: List? = null, + val isACondition: Boolean = false, ) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt index dc909542f0..2812d80d8e 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput @@ -43,10 +44,16 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import org.groundplatform.android.R import org.groundplatform.android.e2etest.TestConfig.DEFAULT_TIMEOUT +import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT +import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT +import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT +import org.groundplatform.android.e2etest.TestConfig.NEXT_BUTTON_TEXT +import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_BUTTON_TEXT import org.groundplatform.android.e2etest.TestConfig.TEST_PHOTO_FILE import org.groundplatform.android.e2etest.extensions.onTarget import org.groundplatform.android.ui.datacollection.tasks.date.DATE_TEXT_TEST_TAG import org.groundplatform.android.ui.datacollection.tasks.time.TIME_TEXT_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.SELECT_MULTIPLE_RADIO_TEST_TAG @OptIn(ExperimentalTestApi::class) class AndroidTestDriver( @@ -88,7 +95,7 @@ class AndroidTestDriver( } } - override fun selectFromList(target: TestDriver.Target, index: Int) { + override fun selectFromList(target: TestDriver.Target, index: Int, isACondition: Boolean) { wait(target) if (target is TestDriver.Target.ViewId) { val resName = composeRule.activity.resources.getResourceEntryName(target.resId) @@ -96,7 +103,20 @@ class AndroidTestDriver( val parent = device.findObject(By.res(packageName, resName)) parent.children[index].click() } else { - composeRule.onTarget(target, index).performClick() + if (target is TestDriver.Target.TestTag && target.tag == SELECT_MULTIPLE_RADIO_TEST_TAG && isACondition) { + composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() + composeRule.onNodeWithText(COFFEE_TEXT).performClick() + composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() + composeRule.onNodeWithText(ARABICA_TEXT).assertExists().performClick() + composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() + composeRule.onTarget(target, index).performClick() + composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() + composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() + composeRule.onNodeWithText(COVER_CROPPING_TEXT).assertExists().performClick() + composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() + } else { + composeRule.onTarget(target, index).performClick() + } } } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt index e179f3c366..b938f10021 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt @@ -18,7 +18,7 @@ package org.groundplatform.android.e2etest.drivers interface TestDriver { fun click(target: Target) - fun selectFromList(target: Target, index: Int) + fun selectFromList(target: Target, index: Int, isACondition: Boolean = false) fun dragMapBy(offsetX: Int, offsetY: Int) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index 3ed98d4672..0944d60b60 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -41,7 +41,7 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot textTask() - Task.Type.MULTIPLE_CHOICE -> multipleChoiceTask(task.selectIndexes!!) + Task.Type.MULTIPLE_CHOICE -> multipleChoiceTask(task.selectIndexes!!, task.isACondition) Task.Type.PHOTO -> cameraTask() Task.Type.NUMBER -> numberTask() Task.Type.DATE -> dateTask() @@ -109,11 +109,12 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot) { + private fun multipleChoiceTask(selectIndexes: List, conditional: Boolean = false) { if (selectIndexes.size == 1) { testDriver.selectFromList( TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), selectIndexes[0], + conditional, ) } else { selectIndexes.forEach { From 04f90848a59ed325b969abc5c016d3f1394938e0 Mon Sep 17 00:00:00 2001 From: Nsubuga Date: Mon, 23 Feb 2026 15:49:54 +0300 Subject: [PATCH 02/13] Reformat code --- .../android/e2etest/TestConfig.kt | 6 +++- .../e2etest/drivers/AndroidTestDriver.kt | 28 +++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index 3c40e0c761..a70a70bd64 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -33,7 +33,11 @@ object TestConfig { TestTask(taskType = Task.Type.DROP_PIN, isRequired = true), TestTask(Task.Type.INSTRUCTIONS), TestTask(Task.Type.TEXT), - TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = listOf(1), isACondition = true), + TestTask( + taskType = Task.Type.MULTIPLE_CHOICE, + selectIndexes = listOf(1), + isACondition = true, + ), TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = (0..3).toList()), TestTask(Task.Type.NUMBER), TestTask(Task.Type.PHOTO), diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt index 2812d80d8e..63ddaba7e8 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -43,10 +43,10 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import org.groundplatform.android.R -import org.groundplatform.android.e2etest.TestConfig.DEFAULT_TIMEOUT import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT +import org.groundplatform.android.e2etest.TestConfig.DEFAULT_TIMEOUT import org.groundplatform.android.e2etest.TestConfig.NEXT_BUTTON_TEXT import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_BUTTON_TEXT import org.groundplatform.android.e2etest.TestConfig.TEST_PHOTO_FILE @@ -103,17 +103,21 @@ class AndroidTestDriver( val parent = device.findObject(By.res(packageName, resName)) parent.children[index].click() } else { - if (target is TestDriver.Target.TestTag && target.tag == SELECT_MULTIPLE_RADIO_TEST_TAG && isACondition) { - composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() - composeRule.onNodeWithText(COFFEE_TEXT).performClick() - composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() - composeRule.onNodeWithText(ARABICA_TEXT).assertExists().performClick() - composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() - composeRule.onTarget(target, index).performClick() - composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() - composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() - composeRule.onNodeWithText(COVER_CROPPING_TEXT).assertExists().performClick() - composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() + if ( + target is TestDriver.Target.TestTag && + target.tag == SELECT_MULTIPLE_RADIO_TEST_TAG && + isACondition + ) { + composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() + composeRule.onNodeWithText(COFFEE_TEXT).performClick() + composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() + composeRule.onNodeWithText(ARABICA_TEXT).assertExists().performClick() + composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() + composeRule.onTarget(target, index).performClick() + composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() + composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() + composeRule.onNodeWithText(COVER_CROPPING_TEXT).assertExists().performClick() + composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() } else { composeRule.onTarget(target, index).performClick() } From aa12bf57ef6ab5dcd0c6fe5b53cd345ad07314ec Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Tue, 24 Feb 2026 15:35:06 +0300 Subject: [PATCH 03/13] Refactor to remove conditional test logic --- .../e2etest/drivers/AndroidTestDriver.kt | 28 ++----------------- .../android/e2etest/drivers/TestDriver.kt | 2 +- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt index 63ddaba7e8..dc909542f0 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput @@ -43,17 +42,11 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import org.groundplatform.android.R -import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT -import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT -import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT import org.groundplatform.android.e2etest.TestConfig.DEFAULT_TIMEOUT -import org.groundplatform.android.e2etest.TestConfig.NEXT_BUTTON_TEXT -import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_BUTTON_TEXT import org.groundplatform.android.e2etest.TestConfig.TEST_PHOTO_FILE import org.groundplatform.android.e2etest.extensions.onTarget import org.groundplatform.android.ui.datacollection.tasks.date.DATE_TEXT_TEST_TAG import org.groundplatform.android.ui.datacollection.tasks.time.TIME_TEXT_TEST_TAG -import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.SELECT_MULTIPLE_RADIO_TEST_TAG @OptIn(ExperimentalTestApi::class) class AndroidTestDriver( @@ -95,7 +88,7 @@ class AndroidTestDriver( } } - override fun selectFromList(target: TestDriver.Target, index: Int, isACondition: Boolean) { + override fun selectFromList(target: TestDriver.Target, index: Int) { wait(target) if (target is TestDriver.Target.ViewId) { val resName = composeRule.activity.resources.getResourceEntryName(target.resId) @@ -103,24 +96,7 @@ class AndroidTestDriver( val parent = device.findObject(By.res(packageName, resName)) parent.children[index].click() } else { - if ( - target is TestDriver.Target.TestTag && - target.tag == SELECT_MULTIPLE_RADIO_TEST_TAG && - isACondition - ) { - composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() - composeRule.onNodeWithText(COFFEE_TEXT).performClick() - composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() - composeRule.onNodeWithText(ARABICA_TEXT).assertExists().performClick() - composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() - composeRule.onTarget(target, index).performClick() - composeRule.onNodeWithText(NEXT_BUTTON_TEXT).assertExists().performClick() - composeRule.onNodeWithText(ARABICA_TEXT).assertDoesNotExist() - composeRule.onNodeWithText(COVER_CROPPING_TEXT).assertExists().performClick() - composeRule.onNodeWithText(PREVIOUS_BUTTON_TEXT).assertExists().performClick() - } else { - composeRule.onTarget(target, index).performClick() - } + composeRule.onTarget(target, index).performClick() } } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt index b938f10021..e179f3c366 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt @@ -18,7 +18,7 @@ package org.groundplatform.android.e2etest.drivers interface TestDriver { fun click(target: Target) - fun selectFromList(target: Target, index: Int, isACondition: Boolean = false) + fun selectFromList(target: Target, index: Int) fun dragMapBy(offsetX: Int, offsetY: Int) From f8635a5c86284e95f2ed8ae46ed94e4af1913af1 Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Wed, 25 Feb 2026 14:39:24 +0300 Subject: [PATCH 04/13] Refactor to seperate responsibility, move conditional test logic to DataCollectionRobot and add a reusable method to AndroidTestDriver --- .../android/e2etest/TestConfig.kt | 1 + .../e2etest/drivers/AndroidTestDriver.kt | 10 ++++ .../android/e2etest/drivers/TestDriver.kt | 2 + .../e2etest/robots/DataCollectionRobot.kt | 52 +++++++++++++++---- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index a70a70bd64..7ad00c957f 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -54,4 +54,5 @@ object TestConfig { const val NEXT_BUTTON_TEXT = "Next" const val PREVIOUS_BUTTON_TEXT = "Previous" const val COVER_CROPPING_TEXT = "Cover Cropping" + const val PALM_TEXT = "Palm" } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt index dc909542f0..1088eb7ed6 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -24,6 +24,8 @@ import android.provider.MediaStore import android.widget.DatePicker import android.widget.TimePicker import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText @@ -168,4 +170,12 @@ class AndroidTestDriver( } override fun getStringResource(id: Int): String = composeRule.activity.getString(id) + + override fun assertVisible(componentText: String, isVisible: Boolean) { + if (isVisible) { + composeRule.onTarget(TestDriver.Target.Text(componentText)).assertIsDisplayed() + } else { + composeRule.onTarget(TestDriver.Target.Text(componentText)).assertIsNotDisplayed() + } + } } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt index e179f3c366..06ff85e171 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt @@ -36,6 +36,8 @@ interface TestDriver { fun getStringResource(id: Int): String + fun assertVisible(componentText: String, isVisible: Boolean = false) + sealed class Target { data class TestTag(val tag: String) : Target() diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index 0944d60b60..a7eec5750b 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -16,7 +16,13 @@ package org.groundplatform.android.e2etest.robots import org.groundplatform.android.R +import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT +import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT +import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT import org.groundplatform.android.e2etest.TestConfig.LOI_NAME +import org.groundplatform.android.e2etest.TestConfig.NEXT_BUTTON_TEXT +import org.groundplatform.android.e2etest.TestConfig.PALM_TEXT +import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_BUTTON_TEXT import org.groundplatform.android.e2etest.TestTask import org.groundplatform.android.e2etest.drivers.TestDriver import org.groundplatform.android.model.task.Task @@ -109,18 +115,26 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot, conditional: Boolean = false) { - if (selectIndexes.size == 1) { - testDriver.selectFromList( - TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), - selectIndexes[0], - conditional, - ) - } else { - selectIndexes.forEach { - testDriver.selectFromList(TestDriver.Target.TestTag(SELECT_MULTIPLE_CHECKBOX_TEST_TAG), it) + private fun multipleChoiceTask(selectIndexes: List, isConditional: Boolean = false) { + when (selectIndexes.size) { + 1 if isConditional -> { + conditionalTask(TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG)) + } + 1 -> { + testDriver.selectFromList( + TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), + selectIndexes[0], + ) + } + else -> { + selectIndexes.forEach { + testDriver.selectFromList( + TestDriver.Target.TestTag(SELECT_MULTIPLE_CHECKBOX_TEST_TAG), + it, + ) + } + testDriver.insertText("Other", TestDriver.Target.TestTag(OTHER_INPUT_TEXT_TEST_TAG)) } - testDriver.insertText("Other", TestDriver.Target.TestTag(OTHER_INPUT_TEXT_TEST_TAG)) } } @@ -139,4 +153,20 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot Date: Fri, 27 Feb 2026 09:48:29 +0530 Subject: [PATCH 05/13] Bump com.google.firebase:firebase-bom from 34.9.0 to 34.10.0 (#3582) Bumps com.google.firebase:firebase-bom from 34.9.0 to 34.10.0. --- updated-dependencies: - dependency-name: com.google.firebase:firebase-bom dependency-version: 34.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a3b0d2704..2a978cee3f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ coreVersion = "1.7.0" coroutinesVersion = "1.10.2" detektVersion = "1.23.8" espressoContribVersion = "3.7.0" -firebaseBomVersion = "34.9.0" +firebaseBomVersion = "34.10.0" firebaseCrashlyticsGradleVersion = "3.0.6" fragmentVersion = "1.8.9" googleServicesVersion = "4.4.4" From 8af1a8d87058eab1a0941b98a9739338a8794f2e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Feb 2026 15:15:01 +0530 Subject: [PATCH 06/13] Refactor settings screen to compose (#3574) * Migrate SettingsActivity to compose * Migrate SettingsFragment.kt to compose * Update unit tests * Remove unused strings * Decouple data layer from SettingsViewModel --- .../android/data/local/LocalValueStore.kt | 10 +- .../data/sync/MediaUploadWorkManager.kt | 2 +- .../android/di/ViewModelModule.kt | 6 - .../android/repository/UserRepository.kt | 10 +- .../android/ui/settings/SettingsActivity.kt | 37 +++- .../android/ui/settings/SettingsFragment.kt | 146 -------------- .../android/ui/settings/SettingsScreen.kt | 160 +++++++++++++++ .../android/ui/settings/SettingsViewModel.kt | 36 ++-- .../settings/components/SettingsCategory.kt | 46 +++++ .../ui/settings/components/SettingsItem.kt | 72 +++++++ .../settings/components/SettingsSelectItem.kt | 112 +++++++++++ .../settings/components/SettingsSwitchItem.kt | 82 ++++++++ .../user/UpdateUserSettingsUseCase.kt | 27 +++ app/src/main/res/layout/settings_activity.xml | 61 ------ app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-pt/strings.xml | 2 - app/src/main/res/values-vi/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - app/src/main/res/xml/preferences.xml | 63 ------ .../android/repository/UserRepositoryTest.kt | 26 +++ .../ui/settings/SettingsFragmentTest.kt | 188 ------------------ .../android/ui/settings/SettingsScreenTest.kt | 186 +++++++++++++++++ .../ui/settings/SettingsViewModelTest.kt | 79 ++++---- .../user/UpdateUserSettingsUseCaseTest.kt | 52 +++++ 25 files changed, 871 insertions(+), 540 deletions(-) delete mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/SettingsFragment.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsCategory.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt create mode 100644 app/src/main/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCase.kt delete mode 100644 app/src/main/res/layout/settings_activity.xml delete mode 100644 app/src/main/res/xml/preferences.xml delete mode 100644 app/src/test/java/org/groundplatform/android/ui/settings/SettingsFragmentTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/settings/SettingsScreenTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCaseTest.kt diff --git a/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt b/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt index 5e5409475a..0a67fcb2c2 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt @@ -115,13 +115,15 @@ constructor(private val preferences: SharedPreferences, private val locale: Loca preferences.edit { putString(PrefKeys.MEASUREMENT_UNITS, value) } } + var shouldUploadMediaOverUnmeteredConnectionOnly: Boolean + get() = allowThreadDiskReads { preferences.getBoolean(PrefKeys.UPLOAD_MEDIA, false) } + set(value) = allowThreadDiskWrites { + preferences.edit { putBoolean(PrefKeys.UPLOAD_MEDIA, value) } + } + /** Removes all values stored in the local store. */ fun clear() = allowThreadDiskWrites { preferences.edit { clear() } } - fun shouldUploadMediaOverUnmeteredConnectionOnly(): Boolean = allowThreadDiskReads { - preferences.getBoolean(PrefKeys.UPLOAD_MEDIA, false) - } - fun clearLastCameraPosition(surveyId: String) = allowThreadDiskReads { preferences.edit { remove(PrefKeys.LAST_VIEWPORT_PREFIX + surveyId) } } diff --git a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorkManager.kt b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorkManager.kt index fb167dc313..30c6e48a3c 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorkManager.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorkManager.kt @@ -27,7 +27,7 @@ class MediaUploadWorkManager constructor(private val workManager: WorkManager, private val localValueStore: LocalValueStore) { private fun preferredNetworkType(): NetworkType = - if (localValueStore.shouldUploadMediaOverUnmeteredConnectionOnly()) NetworkType.UNMETERED + if (localValueStore.shouldUploadMediaOverUnmeteredConnectionOnly) NetworkType.UNMETERED else NetworkType.CONNECTED /** diff --git a/app/src/main/java/org/groundplatform/android/di/ViewModelModule.kt b/app/src/main/java/org/groundplatform/android/di/ViewModelModule.kt index e3e4a8c38d..bdbe0ef14e 100644 --- a/app/src/main/java/org/groundplatform/android/di/ViewModelModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/ViewModelModule.kt @@ -41,7 +41,6 @@ import org.groundplatform.android.ui.main.MainViewModel import org.groundplatform.android.ui.offlineareas.OfflineAreasViewModel import org.groundplatform.android.ui.offlineareas.selector.OfflineAreaSelectorViewModel import org.groundplatform.android.ui.offlineareas.viewer.OfflineAreaViewerViewModel -import org.groundplatform.android.ui.settings.SettingsViewModel import org.groundplatform.android.ui.syncstatus.SyncStatusViewModel import org.groundplatform.android.ui.tos.TermsOfServiceViewModel @@ -143,10 +142,5 @@ abstract class ViewModelModule { @ViewModelKey(BaseMapViewModel::class) abstract fun bindBaseMapViewModel(viewModel: BaseMapViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(SettingsViewModel::class) - abstract fun bindSettingsViewModel(viewModel: SettingsViewModel): ViewModel - @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/org/groundplatform/android/repository/UserRepository.kt b/app/src/main/java/org/groundplatform/android/repository/UserRepository.kt index ba0c254765..889e88b49e 100644 --- a/app/src/main/java/org/groundplatform/android/repository/UserRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/UserRepository.kt @@ -128,7 +128,15 @@ constructor( UserSettings( language = selectedLanguage, measurementUnits = MeasurementUnits.valueOf(selectedLengthUnit), - shouldUploadPhotosOnWifiOnly = shouldUploadMediaOverUnmeteredConnectionOnly(), + shouldUploadPhotosOnWifiOnly = shouldUploadMediaOverUnmeteredConnectionOnly, ) } + + fun setUserSettings(userSettings: UserSettings) { + with(localValueStore) { + selectedLanguage = userSettings.language + selectedLengthUnit = userSettings.measurementUnits.name + shouldUploadMediaOverUnmeteredConnectionOnly = userSettings.shouldUploadPhotosOnWifiOnly + } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsActivity.kt b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsActivity.kt index 5841c75dac..b180d02d4a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsActivity.kt @@ -15,22 +15,45 @@ */ package org.groundplatform.android.ui.settings +import android.content.Intent +import android.net.Uri import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat import dagger.hilt.android.AndroidEntryPoint -import org.groundplatform.android.databinding.SettingsActivityBinding import org.groundplatform.android.ui.common.AbstractActivity +import org.groundplatform.android.ui.main.MainActivity +import org.groundplatform.android.ui.theme.AppTheme @AndroidEntryPoint class SettingsActivity : AbstractActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { + AppTheme { + SettingsScreen( + onBack = { finish() }, + onLocaleChanged = { locale -> applyLocaleAndRestart(locale) }, + onVisitWebsiteClick = { uri: Uri -> openWebsite(uri) }, + ) + } + } + } - val binding = SettingsActivityBinding.inflate(layoutInflater) - setContentView(binding.root) + private fun applyLocaleAndRestart(languageCode: String) { + val appLocale = LocaleListCompat.forLanguageTags(languageCode) + AppCompatDelegate.setApplicationLocales(appLocale) - with(binding.settingsToolbar) { - setSupportActionBar(this) - setNavigationOnClickListener { finish() } - } + val intent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) + } + + private fun openWebsite(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW, uri) + startActivity(intent) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsFragment.kt deleted file mode 100644 index ce78a8d7f2..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsFragment.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.android.ui.settings - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.net.toUri -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.preference.DropDownPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import org.groundplatform.android.R -import org.groundplatform.android.common.Constants -import org.groundplatform.android.common.PrefKeys -import org.groundplatform.android.ui.common.ViewModelFactory -import org.groundplatform.android.ui.main.MainActivity - -/** Fragment containing app preferences saved as shared preferences. */ -@AndroidEntryPoint -class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener { - - @Inject lateinit var viewModelFactory: ViewModelFactory - private lateinit var viewModel: SettingsViewModel - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel = viewModelFactory[this, SettingsViewModel::class.java] - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.sharedPreferencesName = Constants.SHARED_PREFS_NAME - preferenceManager.sharedPreferencesMode = Constants.SHARED_PREFS_MODE - - setPreferencesFromResource(R.xml.preferences, rootKey) - for (key in ALL_KEYS) { - val preference = - findPreference(key) - ?: throw IllegalArgumentException("Key not found in preferences.xml: $key") - - preference.onPreferenceClickListener = this - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.filterNotNull().collect { state -> - val switchPreference = findPreference(PrefKeys.UPLOAD_MEDIA) - switchPreference?.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _, _ -> - viewModel.refreshUserPreferences() - true - } - switchPreference?.isChecked = state.shouldUploadPhotosOnWifiOnly - - setupDropDownPreference(PrefKeys.LANGUAGE, state.language) { applyLocaleAndRestart(it) } - setupDropDownPreference(PrefKeys.MEASUREMENT_UNITS, state.measurementUnits.name) - } - } - } - } - - private fun setupDropDownPreference( - prefKey: String, - selectedValue: String, - onPrefChanged: (String) -> Unit = {}, - ) { - findPreference(prefKey)?.apply { - updateSummary(selectedValue) - onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { preference, newValue -> - if (newValue is String) { - updateSummary(newValue) - onPrefChanged(newValue) - } - viewModel.refreshUserPreferences() - true - } - } - } - - private fun DropDownPreference.updateSummary(value: String) { - val index = findIndexOfValue(value) - if (index >= 0) { - summary = entries[index] - } - } - - override fun onPreferenceClick(preference: Preference): Boolean { - if (preference.key == PrefKeys.VISIT_WEBSITE) { - openUrl(preference.summary.toString()) - } - return true - } - - private fun openUrl(url: String) { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) - } - - private fun applyLocaleAndRestart(languageCode: String) { - val appLocale = LocaleListCompat.forLanguageTags(languageCode) - AppCompatDelegate.setApplicationLocales(appLocale) - - val intent = - Intent(requireContext(), MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - startActivity(intent) - } - - companion object { - private val ALL_KEYS = - arrayOf( - PrefKeys.LANGUAGE, - PrefKeys.MEASUREMENT_UNITS, - PrefKeys.UPLOAD_MEDIA, - PrefKeys.VISIT_WEBSITE, - ) - } -} diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000000..b0c39af89f --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.model.settings.MeasurementUnits +import org.groundplatform.android.model.settings.UserSettings +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.Toolbar +import org.groundplatform.android.ui.settings.components.SettingsCategory +import org.groundplatform.android.ui.settings.components.SettingsItem +import org.groundplatform.android.ui.settings.components.SettingsSelectItem +import org.groundplatform.android.ui.settings.components.SettingsSwitchItem +import org.groundplatform.android.ui.theme.AppTheme + +/** + * Stateful composable for the settings screen. + * + * @param onBack callback to be invoked when the back button is clicked. + * @param onLocaleChanged callback to be invoked when the application language is changed. + * @param onVisitWebsiteClick callback to be invoked when the "Visit website" item is clicked. + * @param viewModel the [SettingsViewModel] for managing user settings state. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + onLocaleChanged: (String) -> Unit, + onVisitWebsiteClick: (url: Uri) -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val websiteUrl = stringResource(R.string.ground_website) + + SettingsScreen( + settings = uiState, + onUploadMediaOverUnmeteredConnectionOnlyChange = { + viewModel.updateUploadMediaOverUnmeteredConnectionOnly(it) + }, + onLanguageChange = { + viewModel.updateSelectedLanguage(it) + onLocaleChanged(it) + }, + onMeasurementUnitsChange = { viewModel.updateMeasurementUnits(it) }, + onVisitWebsiteClick = { onVisitWebsiteClick(websiteUrl.toUri()) }, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@VisibleForTesting +@Composable +internal fun SettingsScreen( + settings: UserSettings, + onUploadMediaOverUnmeteredConnectionOnlyChange: (Boolean) -> Unit, + onLanguageChange: (String) -> Unit, + onMeasurementUnitsChange: (MeasurementUnits) -> Unit, + onVisitWebsiteClick: () -> Unit, + onBack: () -> Unit, +) { + + Scaffold( + topBar = { + Toolbar(stringRes = R.string.settings, showNavigationIcon = true, iconClick = onBack) + } + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { + // General Section + SettingsCategory(stringResource(R.string.general_title)) { + // Upload Media + SettingsSwitchItem( + title = stringResource(R.string.upload_media_title), + summary = stringResource(R.string.over_wifi_summary), + checked = settings.shouldUploadPhotosOnWifiOnly, + onCheckedChange = onUploadMediaOverUnmeteredConnectionOnlyChange, + ) + + // Language + SettingsSelectItem( + title = stringResource(R.string.select_language_title), + entriesResId = R.array.language_entries, + entryValues = R.array.language_entry_values, + currentValue = settings.language, + onValueChanged = { onLanguageChange(it) }, + ) + + // Measurement Units + SettingsSelectItem( + title = stringResource(R.string.select_length_title), + entriesResId = R.array.length_entries, + entryValues = R.array.length_entry_values, + currentValue = settings.measurementUnits.name, + onValueChanged = { onMeasurementUnitsChange(MeasurementUnits.valueOf(it)) }, + ) + } + + HorizontalDivider() + + // Help Section + SettingsCategory(stringResource(R.string.help_title)) { + SettingsItem( + title = stringResource(R.string.visit_website_title), + summary = stringResource(R.string.ground_website), + onClick = onVisitWebsiteClick, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun SettingsScreenPreview() { + AppTheme { + SettingsScreen( + settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = true, + ), + onUploadMediaOverUnmeteredConnectionOnlyChange = {}, + onLanguageChange = {}, + onMeasurementUnitsChange = {}, + onVisitWebsiteClick = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsViewModel.kt index 0b239b7bb8..b5d7621d66 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsViewModel.kt @@ -15,31 +15,41 @@ */ package org.groundplatform.android.ui.settings -import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch +import org.groundplatform.android.model.settings.MeasurementUnits import org.groundplatform.android.model.settings.UserSettings import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.usecases.user.GetUserSettingsUseCase +import org.groundplatform.android.usecases.user.UpdateUserSettingsUseCase +@HiltViewModel class SettingsViewModel @Inject -internal constructor(private val getUserSettingsUseCase: GetUserSettingsUseCase) : - AbstractViewModel() { +internal constructor( + getUserSettingsUseCase: GetUserSettingsUseCase, + private val updateUserSettingsUseCase: UpdateUserSettingsUseCase, +) : AbstractViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(null) - val uiState: StateFlow = _uiState + private val _uiState: MutableStateFlow = MutableStateFlow(getUserSettingsUseCase()) + val uiState: StateFlow = _uiState - init { - refreshUserPreferences() + private fun updateState(newUiState: UserSettings) { + _uiState.value = newUiState + updateUserSettingsUseCase(newUiState) } - fun refreshUserPreferences() { - viewModelScope.launch { - val prefs = getUserSettingsUseCase.invoke() - _uiState.value = prefs - } + fun updateSelectedLanguage(language: String) { + updateState(_uiState.value.copy(language = language)) + } + + fun updateMeasurementUnits(measurementUnits: MeasurementUnits) { + updateState(_uiState.value.copy(measurementUnits = measurementUnits)) + } + + fun updateUploadMediaOverUnmeteredConnectionOnly(enabled: Boolean) { + updateState(_uiState.value.copy(shouldUploadPhotosOnWifiOnly = enabled)) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsCategory.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsCategory.kt new file mode 100644 index 0000000000..896f4ae710 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsCategory.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A composable that groups related settings under a labeled category. + * + * @param title The text to be displayed as the header for the category. + * @param content The composable content to be displayed within the category, usually a list of + * settings items. + */ +@Composable +internal fun SettingsCategory(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp), + ) + content() + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt new file mode 100644 index 0000000000..f1f635c511 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.theme.AppTheme + +/** + * A reusable UI component representing a single row in a settings screen. + * + * @param title The primary text to be displayed for the setting. + * @param summary Optional secondary text to be displayed below the title, providing more detail. + * @param onClick The callback to be invoked when the item is clicked. + */ +@Composable +internal fun SettingsItem(title: String, summary: String? = null, onClick: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth().clickable(onClick = onClick, role = Role.Button).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun Preview() { + AppTheme { + Column(verticalArrangement = Arrangement.SpaceEvenly) { + SettingsItem(title = "Name", summary = "Summary", onClick = {}) + SettingsItem(title = "Name", summary = null, onClick = {}) + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt new file mode 100644 index 0000000000..9cece8ac40 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.theme.AppTheme + +/** + * A settings item that allows users to select a single value from a list of options. + * + * When clicked, it displays a dropdown menu with options populated from the provided resource IDs. + * + * @param title The title of the settings item. + * @param entriesResId The resource ID of the string array containing the display labels. + * @param entryValues The resource ID of the string array containing the underlying values. + * @param currentValue The currently selected value. + * @param onValueChanged Callback triggered when a new value is selected. + */ +@Composable +internal fun SettingsSelectItem( + title: String, + entriesResId: Int, + entryValues: Int, + currentValue: String, + onValueChanged: (String) -> Unit, +) { + val configuration = LocalConfiguration.current + val resources = LocalResources.current + + val allOptions = + remember(configuration) { + val labels = resources.getStringArray(entriesResId) + val values = resources.getStringArray(entryValues) + labels.zip(values) { label, value -> Option(label, value) } + } + + val selectedOption = allOptions.find { it.value == currentValue } ?: allOptions.firstOrNull() + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + SettingsItem( + title = title, + summary = selectedOption?.label ?: "", + onClick = { expanded = true }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(16.dp, 0.dp), + modifier = Modifier.widthIn(min = 200.dp), + ) { + allOptions.forEach { option -> + DropdownMenuItem( + text = { Text(text = option.label) }, + onClick = { + onValueChanged(option.value) + expanded = false + }, + ) + } + } + } +} + +internal data class Option(val label: String, val value: String) + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun PreviewSelectItem() { + AppTheme { + SettingsSelectItem( + title = "Language", + entriesResId = R.array.language_entries, + entryValues = R.array.language_entry_values, + currentValue = "en", + onValueChanged = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt new file mode 100644 index 0000000000..9a10b84b00 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.theme.AppTheme + +/** + * A reusable settings item component with a title, optional summary, and a switch toggle. + * + * @param title The primary text to be displayed for the setting. + * @param summary Optional secondary text providing additional details about the setting. + * @param checked Whether the switch is currently in the "on" position. + * @param onCheckedChange Callback to be invoked when the switch state is toggled. + */ +@Composable +internal fun SettingsSwitchItem( + title: String, + summary: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier.fillMaxWidth() + .toggleable(value = checked, onValueChange = onCheckedChange, role = Role.Switch) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch(checked = checked, onCheckedChange = null) + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun Preview() { + AppTheme { + Column(verticalArrangement = Arrangement.SpaceEvenly) { + SettingsSwitchItem(title = "Name", summary = "Value", checked = true, onCheckedChange = {}) + SettingsSwitchItem(title = "Name", summary = null, checked = false, onCheckedChange = {}) + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCase.kt b/app/src/main/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCase.kt new file mode 100644 index 0000000000..c69e141d6d --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCase.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.usecases.user + +import javax.inject.Inject +import org.groundplatform.android.model.settings.UserSettings +import org.groundplatform.android.repository.UserRepository + +class UpdateUserSettingsUseCase @Inject constructor(private val userRepository: UserRepository) { + + operator fun invoke(userSettings: UserSettings) { + userRepository.setUserSettings(userSettings) + } +} diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml deleted file mode 100644 index 4b7651a229..0000000000 --- a/app/src/main/res/layout/settings_activity.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 46fc1bf5a1..a91d55ae0d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -172,7 +172,6 @@ Visitar sitio web Precisión: Seleccionar idioma - Selecciona tu idioma preferido *No hay términos para mostrar.* @@ -226,7 +225,6 @@ Actualizar Seleccionar unidades - Seleccione su unidad preferida Métrica Imperial diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f3e620400f..412f78a3ea 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -159,7 +159,6 @@ Visiter le site web Précision : Choix de la langue - Choisissez votre langue *Aucune condition à afficher.* @@ -205,7 +204,6 @@ Mettre à jour Sélectionner les unités - Sélectionnez votre unité préférée Métrique Impérial diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2a577219b7..06e0f34f4b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -173,7 +173,6 @@ Visitar site Precisão: Selecionar idioma - Selecione o seu idioma preferido *Não há termos para exibir.* @@ -227,7 +226,6 @@ Atualizar Selecionar unidades - Selecione a sua unidade preferida Métrica Imperial diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 73379d30b8..36da623091 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -158,7 +158,6 @@ Truy cập trang web Độ chính xác: Chọn ngôn ngữ - Chọn ngôn ngữ ưu tiên *Không có điều khoản nào để hiển thị* ## Chia sẻ dữ liệu riêng tư @@ -199,7 +198,6 @@ A new version of the app is available. Please update to continue using the app. Cập nhật Chọn đơn vị - Chọn đơn vị ưa thích của bạn Hệ mét Hệ Anh diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dd654b01c..133695b018 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,7 +172,6 @@ Visit website Accuracy: Select language - Select your preferred language *No terms to display.* @@ -225,7 +224,6 @@ Update Select units - Select your preferred unit Metric Imperial diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml deleted file mode 100644 index d320072b69..0000000000 --- a/app/src/main/res/xml/preferences.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/test/java/org/groundplatform/android/repository/UserRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/UserRepositoryTest.kt index 37af310384..82275187ae 100644 --- a/app/src/test/java/org/groundplatform/android/repository/UserRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/UserRepositoryTest.kt @@ -29,6 +29,8 @@ import org.groundplatform.android.data.local.stores.LocalSurveyStore import org.groundplatform.android.data.local.stores.LocalUserStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.model.Role +import org.groundplatform.android.model.settings.MeasurementUnits +import org.groundplatform.android.model.settings.UserSettings import org.groundplatform.android.proto.Survey import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.system.auth.FakeAuthenticationManager @@ -172,4 +174,28 @@ class UserRepositoryTest : BaseHiltTest() { assertThat(userRepository.canUserSubmitData()).isFalse() } + + @Test + fun `getUserSettings() returns correct settings`() { + localValueStore.selectedLanguage = "fr" + localValueStore.selectedLengthUnit = MeasurementUnits.IMPERIAL.name + localValueStore.shouldUploadMediaOverUnmeteredConnectionOnly = true + + val settings = userRepository.getUserSettings() + + assertThat(settings.language).isEqualTo("fr") + assertThat(settings.measurementUnits).isEqualTo(MeasurementUnits.IMPERIAL) + assertThat(settings.shouldUploadPhotosOnWifiOnly).isTrue() + } + + @Test + fun `setUserSettings() updates local store`() { + val settings = UserSettings("fr", MeasurementUnits.IMPERIAL, true) + + userRepository.setUserSettings(settings) + + assertThat(localValueStore.selectedLanguage).isEqualTo("fr") + assertThat(localValueStore.selectedLengthUnit).isEqualTo(MeasurementUnits.IMPERIAL.name) + assertThat(localValueStore.shouldUploadMediaOverUnmeteredConnectionOnly).isTrue() + } } diff --git a/app/src/test/java/org/groundplatform/android/ui/settings/SettingsFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/settings/SettingsFragmentTest.kt deleted file mode 100644 index d1a99b3a64..0000000000 --- a/app/src/test/java/org/groundplatform/android/ui/settings/SettingsFragmentTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.android.ui.settings - -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.preference.DropDownPreference -import androidx.preference.PreferenceCategory -import androidx.preference.PreferenceManager -import androidx.preference.SwitchPreferenceCompat -import androidx.preference.get -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import java.util.Locale -import kotlin.test.assertEquals -import org.groundplatform.android.BaseHiltTest -import org.groundplatform.android.testrules.FragmentScenarioRule -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -class SettingsFragmentTest : BaseHiltTest() { - @get:Rule val fragmentScenario = FragmentScenarioRule() - - private lateinit var fragment: SettingsFragment - - @Before - override fun setUp() { - super.setUp() - resetPreferences() - fragmentScenario.launchFragmentInHiltContainer() { - fragment = this as SettingsFragment - } - } - - private fun resetPreferences() { - PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) - .edit() - .clear() - .commit() - - AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) - } - - @Test - fun `category titles are displayed correctly`() { - assertHasCategory("general_category").apply { assertThat(title).isEqualTo("General") } - - assertHasCategory("help_category").apply { assertThat(title).isEqualTo("Help") } - } - - @Test - fun `general category items are displayed`() { - val category = assertHasCategory("general_category") - - assertThat(category.preferenceCount).isEqualTo(3) - - category.getPreference(0).apply { - assertThat(title).isEqualTo("Upload photos") - assertThat(summary).isEqualTo("Over Wi-Fi only") - } - - category.getPreference(1).apply { - assertThat(title).isEqualTo("Select language") - assertThat(summary).isEqualTo("English") - } - - category.getPreference(2).apply { - assertThat(title).isEqualTo("Select units") - assertThat(summary).isEqualTo("Metric") - } - } - - @Test - fun `help category items are displayed`() { - val item = assertHasCategory("help_category") - - val items = item.preferenceCount - assertThat(items).isEqualTo(1) - - val preferenceHelp = item.getPreference(0) - assertThat(preferenceHelp.title.toString()).isEqualTo("Visit website") - assertThat(preferenceHelp.summary).isEqualTo("https://groundplatform.org/") - } - - @Test - fun `visit website click opens correct page`() { - val item = assertHasCategory("help_category") - - val preferenceWebsite = item.getPreference(0) - preferenceWebsite.performClick() - - assertEquals( - preferenceWebsite.summary, - Shadows.shadowOf(fragment.activity).nextStartedActivity.data.toString(), - ) - } - - @Test - fun `when shared preferences is null should use device default language`() = - runWithTestDispatcher { - val mockedPreferenceManager = mock() - whenever(mockedPreferenceManager.sharedPreferences).thenReturn(null) - - val generalCategory = assertHasCategory("general_category") - val languagePreference = generalCategory.getPreference(1) as? DropDownPreference - val defaultLanguageCode = Locale.getDefault().language - val expectedSummary = - languagePreference?.let { pref -> - val index = pref.findIndexOfValue(defaultLanguageCode) - if (index >= 0) pref.entries[index].toString() else defaultLanguageCode - } ?: defaultLanguageCode - assertThat(languagePreference?.summary).isEqualTo(expectedSummary) - } - - @Test - fun `Change app language to french`() = runWithTestDispatcher { - val generalCategory = assertHasCategory("general_category") - - val languagePreference = generalCategory.getPreference(1) as? DropDownPreference - assertThat(languagePreference).isNotNull() - assertThat(languagePreference!!.summary).isEqualTo("English") - - languagePreference.value = "fr" - languagePreference.summary = - languagePreference.entries[languagePreference.findIndexOfValue("fr")] - val changeListener = languagePreference.onPreferenceChangeListener - - assertThat(changeListener).isNotNull() - assertThat(languagePreference.summary.toString()).isEqualTo("French") - assertThat(languagePreference.value).isEqualTo("fr") - } - - @Test - fun `Should change preferred units between imperial and metric correctly`() = - runWithTestDispatcher { - val generalCategory = assertHasCategory("general_category") - val preference = generalCategory.getPreference(2) as DropDownPreference - - val listener = preference.onPreferenceChangeListener - listener?.onPreferenceChange(preference, "IMPERIAL") - assertThat(preference.summary).isEqualTo("Imperial") - - listener?.onPreferenceChange(preference, "METRIC") - assertThat(preference.summary).isEqualTo("Metric") - } - - @Test - fun `Should update photo upload preference correctly`() = runWithTestDispatcher { - val generalCategory = assertHasCategory("general_category") - val preference = generalCategory.getPreference(0) as SwitchPreferenceCompat - - val initial = preference.summary.toString() - assertThat(initial).isAnyOf("Over Wi-Fi only", "Off") - - preference.performClick() - - val updatedSummary = preference.summary.toString() - assertThat(updatedSummary).isAnyOf("Over Wi-Fi only", "On") - } - - private fun assertHasCategory(key: String): PreferenceCategory { - val item = fragment.findPreference(key) - assertThat(item).isNotNull() - return item!! - } -} diff --git a/app/src/test/java/org/groundplatform/android/ui/settings/SettingsScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/settings/SettingsScreenTest.kt new file mode 100644 index 0000000000..d341af828c --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/settings/SettingsScreenTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.model.settings.MeasurementUnits +import org.groundplatform.android.model.settings.UserSettings +import org.groundplatform.android.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testSettingsScreen_InitialState() { + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = true, + ) + + composeTestRule.setContent { + AppTheme { + SettingsScreen( + settings = settings, + onUploadMediaOverUnmeteredConnectionOnlyChange = {}, + onLanguageChange = {}, + onMeasurementUnitsChange = {}, + onVisitWebsiteClick = {}, + onBack = {}, + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.general_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.upload_media_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.select_language_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.select_length_title)).assertIsDisplayed() + composeTestRule + .onNodeWithText(getString(R.string.help_title)) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithText(getString(R.string.visit_website_title)) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun testSettingsScreen_ToggleUploadMedia() { + var uploadMediaChecked = false + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = false, + ) + + composeTestRule.setContent { + AppTheme { + SettingsScreen( + settings = settings, + onUploadMediaOverUnmeteredConnectionOnlyChange = { uploadMediaChecked = it }, + onLanguageChange = {}, + onMeasurementUnitsChange = {}, + onVisitWebsiteClick = {}, + onBack = {}, + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.upload_media_title)).performClick() + assert(uploadMediaChecked) + } + + @Test + fun testSettingsScreen_ChangeLanguage() { + var selectedLanguage: String? = null + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = false, + ) + + composeTestRule.setContent { + AppTheme { + SettingsScreen( + settings = settings, + onUploadMediaOverUnmeteredConnectionOnlyChange = {}, + onLanguageChange = { selectedLanguage = it }, + onMeasurementUnitsChange = {}, + onVisitWebsiteClick = {}, + onBack = {}, + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.select_language_title)).performClick() + composeTestRule.onNodeWithText(getString(R.string.lang_french)).performClick() + assert(selectedLanguage == "fr") + } + + @Test + fun testSettingsScreen_ChangeUnits() { + var selectedUnits: MeasurementUnits? = null + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = false, + ) + + composeTestRule.setContent { + AppTheme { + SettingsScreen( + settings = settings, + onUploadMediaOverUnmeteredConnectionOnlyChange = {}, + onLanguageChange = {}, + onMeasurementUnitsChange = { selectedUnits = it }, + onVisitWebsiteClick = {}, + onBack = {}, + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.select_length_title)).performClick() + composeTestRule.onNodeWithText(getString(R.string.length_imperial)).performClick() + assert(selectedUnits == MeasurementUnits.IMPERIAL) + } + + @Test + fun testSettingsScreen_VisitWebsite() { + var visited = false + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = false, + ) + + composeTestRule.setContent { + AppTheme { + SettingsScreen( + settings = settings, + onUploadMediaOverUnmeteredConnectionOnlyChange = {}, + onLanguageChange = {}, + onMeasurementUnitsChange = {}, + onVisitWebsiteClick = { visited = true }, + onBack = {}, + ) + } + } + + composeTestRule + .onNodeWithText(getString(R.string.visit_website_title)) + .performScrollTo() + .performClick() + assert(visited) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/settings/SettingsViewModelTest.kt index b65ecf0f81..f7b62c35f3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/settings/SettingsViewModelTest.kt @@ -16,25 +16,20 @@ package org.groundplatform.android.ui.settings import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlinx.coroutines.Dispatchers +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.groundplatform.android.model.settings.MeasurementUnits import org.groundplatform.android.model.settings.UserSettings import org.groundplatform.android.usecases.user.GetUserSettingsUseCase -import org.junit.After +import org.groundplatform.android.usecases.user.UpdateUserSettingsUseCase import org.junit.Before import org.junit.Rule +import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations -import org.mockito.kotlin.times -import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class SettingsViewModelTest { @@ -42,57 +37,61 @@ class SettingsViewModelTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() @Mock lateinit var getUserSettingsUseCase: GetUserSettingsUseCase + @Mock lateinit var updateUserSettingsUseCase: UpdateUserSettingsUseCase private lateinit var viewModel: SettingsViewModel - private val testDispatcher = StandardTestDispatcher() - @Before fun setup() { MockitoAnnotations.openMocks(this) - Dispatchers.setMain(testDispatcher) } - @After - fun tearDown() { - Dispatchers.resetMain() + @Test + fun `uiState is populated with initial user settings`() = runTest { + val userSettings = UserSettings("en", MeasurementUnits.METRIC, false) + `when`(getUserSettingsUseCase()).thenReturn(userSettings) + + viewModel = SettingsViewModel(getUserSettingsUseCase, updateUserSettingsUseCase) + + assertThat(viewModel.uiState.value).isEqualTo(userSettings) } @Test - fun `uiState is populated with correct user settings`() = runTest { - val userSettings = - UserSettings( - language = "en", - measurementUnits = MeasurementUnits.METRIC, - shouldUploadPhotosOnWifiOnly = false, - ) - whenever(getUserSettingsUseCase.invoke()).thenReturn(userSettings) + fun `updateSelectedLanguage updates use case and uiState`() = runTest { + val initialSettings = UserSettings("en", MeasurementUnits.METRIC, false) + `when`(getUserSettingsUseCase()).thenReturn(initialSettings) + viewModel = SettingsViewModel(getUserSettingsUseCase, updateUserSettingsUseCase) - viewModel = SettingsViewModel(getUserSettingsUseCase) + viewModel.updateSelectedLanguage("fr") - testDispatcher.scheduler.advanceUntilIdle() - - assertEquals(userSettings, viewModel.uiState.value) - verify(getUserSettingsUseCase).invoke() + val expectedSettings = initialSettings.copy(language = "fr") + verify(updateUserSettingsUseCase).invoke(expectedSettings) + assertThat(viewModel.uiState.value.language).isEqualTo("fr") } @Test - fun `Refreshing preferences updates the uiState again`() = runTest { - val settings1 = UserSettings("en", MeasurementUnits.METRIC, false) - val settings2 = UserSettings("fr", MeasurementUnits.IMPERIAL, true) + fun `updateMeasurementUnits updates use case and uiState`() = runTest { + val initialSettings = UserSettings("en", MeasurementUnits.METRIC, false) + `when`(getUserSettingsUseCase()).thenReturn(initialSettings) + viewModel = SettingsViewModel(getUserSettingsUseCase, updateUserSettingsUseCase) - whenever(getUserSettingsUseCase.invoke()).thenReturn(settings1) + viewModel.updateMeasurementUnits(MeasurementUnits.IMPERIAL) - viewModel = SettingsViewModel(getUserSettingsUseCase) - testDispatcher.scheduler.advanceUntilIdle() - assertEquals(settings1, viewModel.uiState.value) + val expectedSettings = initialSettings.copy(measurementUnits = MeasurementUnits.IMPERIAL) + verify(updateUserSettingsUseCase).invoke(expectedSettings) + assertThat(viewModel.uiState.value.measurementUnits).isEqualTo(MeasurementUnits.IMPERIAL) + } - whenever(getUserSettingsUseCase.invoke()).thenReturn(settings2) + @Test + fun `updateUploadMediaOverUnmeteredConnectionOnly updates use case and uiState`() = runTest { + val initialSettings = UserSettings("en", MeasurementUnits.METRIC, false) + `when`(getUserSettingsUseCase()).thenReturn(initialSettings) + viewModel = SettingsViewModel(getUserSettingsUseCase, updateUserSettingsUseCase) - viewModel.refreshUserPreferences() - testDispatcher.scheduler.advanceUntilIdle() + viewModel.updateUploadMediaOverUnmeteredConnectionOnly(true) - assertEquals(settings2, viewModel.uiState.value) - verify(getUserSettingsUseCase, times(2)).invoke() + val expectedSettings = initialSettings.copy(shouldUploadPhotosOnWifiOnly = true) + verify(updateUserSettingsUseCase).invoke(expectedSettings) + assertThat(viewModel.uiState.value.shouldUploadPhotosOnWifiOnly).isTrue() } } diff --git a/app/src/test/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCaseTest.kt b/app/src/test/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCaseTest.kt new file mode 100644 index 0000000000..e22af0ae53 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/usecases/user/UpdateUserSettingsUseCaseTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.usecases.user + +import org.groundplatform.android.model.settings.MeasurementUnits +import org.groundplatform.android.model.settings.UserSettings +import org.groundplatform.android.repository.UserRepository +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +class UpdateUserSettingsUseCaseTest { + + @Mock lateinit var userRepository: UserRepository + + private lateinit var useCase: UpdateUserSettingsUseCase + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + useCase = UpdateUserSettingsUseCase(userRepository) + } + + @Test + fun `invoke updates user settings in repository`() { + val settings = + UserSettings( + language = "en", + measurementUnits = MeasurementUnits.METRIC, + shouldUploadPhotosOnWifiOnly = true, + ) + + useCase(settings) + + verify(userRepository).setUserSettings(settings) + } +} From 335a90c9f7da54cae54b5873de827d9d5cbdf170 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Feb 2026 15:28:57 +0530 Subject: [PATCH 07/13] Exclude Compose previews from Jacoco reports (#3584) --- .../android/ui/components/MapFloatingActionButton.kt | 2 ++ .../org/groundplatform/android/ui/components/RecenterButton.kt | 2 ++ .../ui/home/mapcontainer/HomeScreenMapContainerScreen.kt | 2 ++ .../android/ui/home/mapcontainer/jobs/JobMapComponent.kt | 2 ++ .../android/ui/surveyselector/components/SurveyEmptyState.kt | 2 ++ 5 files changed, 10 insertions(+) diff --git a/app/src/main/java/org/groundplatform/android/ui/components/MapFloatingActionButton.kt b/app/src/main/java/org/groundplatform/android/ui/components/MapFloatingActionButton.kt index 297ed0b839..93252c4acf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/components/MapFloatingActionButton.kt +++ b/app/src/main/java/org/groundplatform/android/ui/components/MapFloatingActionButton.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.theme.AppTheme const val OPEN_NAV_DRAWER_TEST_TAG = "open_nav_drawer" @@ -86,6 +87,7 @@ sealed class MapFloatingActionButtonType( @Preview @Composable +@ExcludeFromJacocoGeneratedReport private fun MapFloatingActionButtonPreview() { AppTheme { MapFloatingActionButton(type = MapFloatingActionButtonType.LocationLocked(), onClick = {}) diff --git a/app/src/main/java/org/groundplatform/android/ui/components/RecenterButton.kt b/app/src/main/java/org/groundplatform/android/ui/components/RecenterButton.kt index 806ab43467..3eb2a98d83 100644 --- a/app/src/main/java/org/groundplatform/android/ui/components/RecenterButton.kt +++ b/app/src/main/java/org/groundplatform/android/ui/components/RecenterButton.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.theme.AppTheme @Composable @@ -56,6 +57,7 @@ fun RecenterButton(modifier: Modifier = Modifier, onClick: () -> Unit) { @Preview @Composable +@ExcludeFromJacocoGeneratedReport private fun RecenterButtonPreview() { AppTheme { RecenterButton(onClick = {}) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt index 25dc2464ab..0551fd33e5 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.Style +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.components.MapFloatingActionButton import org.groundplatform.android.ui.components.MapFloatingActionButtonType import org.groundplatform.android.ui.components.RecenterButton @@ -128,6 +129,7 @@ sealed interface BaseMapAction { @Preview(showSystemUi = true) @Composable +@ExcludeFromJacocoGeneratedReport private fun HomeScreenMapContainerScreenPreview() { AppTheme { HomeScreenMapContainerScreen( diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt index 23cc046515..029dd59a8d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import org.groundplatform.android.R import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.Style +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnAddDataClicked import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnDeleteSiteClicked import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnJobSelected @@ -122,6 +123,7 @@ sealed interface JobMapComponentAction { @Preview @Composable +@ExcludeFromJacocoGeneratedReport private fun JobMapComponentPreview() { AppTheme { JobMapComponent( diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/components/SurveyEmptyState.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/components/SurveyEmptyState.kt index 8c11196c10..769cdda007 100644 --- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/components/SurveyEmptyState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/components/SurveyEmptyState.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.theme.AppTheme @Composable @@ -51,6 +52,7 @@ fun SurveyEmptyState(onSignOut: () -> Unit) { @Preview(showBackground = true) @Composable +@ExcludeFromJacocoGeneratedReport fun SurveyEmptyStatePreview() { AppTheme { SurveyEmptyState(onSignOut = {}) } } From 3c5c972609bf623938ea49d80ecbfcf0e6558f0b Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Feb 2026 16:45:11 +0530 Subject: [PATCH 08/13] Refactor show sign out confirmation dialogs (#3583) --- .../android/ui/home/HomeScreenFragment.kt | 63 ++++------- .../android/ui/home/HomeScreenViewModel.kt | 40 +++++++ .../android/ui/home/UserAccountDialogs.kt | 50 +++++++++ .../ui/home/HomeScreenViewModelTest.kt | 95 ++++++++++++++++ .../android/ui/home/UserAccountDialogsTest.kt | 105 ++++++++++++++++++ 5 files changed, 309 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt index 4875defa89..538c7ddeaa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt @@ -21,10 +21,12 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.core.view.GravityCompat import androidx.core.view.WindowInsetsCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.imageview.ShapeableImageView @@ -37,12 +39,10 @@ import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.databinding.HomeScreenFragBinding import org.groundplatform.android.databinding.NavDrawerHeaderBinding -import org.groundplatform.android.model.User import org.groundplatform.android.repository.UserRepository import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.main.MainViewModel import org.groundplatform.android.util.setComposableContent import org.groundplatform.android.util.systemInsets @@ -60,7 +60,6 @@ class HomeScreenFragment : @Inject lateinit var userRepository: UserRepository private lateinit var binding: HomeScreenFragBinding private lateinit var homeScreenViewModel: HomeScreenViewModel - private lateinit var user: User override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -96,9 +95,9 @@ class HomeScreenFragment : HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment(false) ) } - viewLifecycleOwner.lifecycleScope.launch { user = userRepository.getAuthenticatedUser() } + navHeader.findViewById(R.id.user_image).setOnClickListener { - showSignOutConfirmationDialogs() + homeScreenViewModel.showUserDetails() } updateNavHeader() // Re-open data collection screen if draft submission is present. @@ -131,6 +130,8 @@ class HomeScreenFragment : val navigationView = view.findViewById(R.id.nav_view) val menuItem = navigationView.menu.findItem(R.id.nav_log_version) menuItem.title = String.format(getString(R.string.build), BuildConfig.VERSION_NAME) + + binding.composeView.setComposableContent { SetupUserConfirmationDialog() } } private fun updateNavHeader() = @@ -192,43 +193,17 @@ class HomeScreenFragment : return true } - private fun showSignOutConfirmationDialogs() { - val showUserDetailsDialog = mutableStateOf(false) - val showSignOutDialog = mutableStateOf(false) - - fun showUserDetailsDialog() { - showUserDetailsDialog.value = true - showSignOutDialog.value = false - } - - fun showSignOutDialog() { - showUserDetailsDialog.value = false - showSignOutDialog.value = true - } - - fun hideAllDialogs() { - showUserDetailsDialog.value = false - showSignOutDialog.value = false - } - - // Init state for composition - showUserDetailsDialog() - - // Note: Adding a compose view to the fragment's view dynamically causes the navigation click to - // stop working after 1st time. Revisit this once the navigation drawer is also generated using - // compose. - binding.composeView.setComposableContent { - if (showUserDetailsDialog.value) { - UserDetailsDialog(user, { showSignOutDialog() }, { hideAllDialogs() }) - } - if (showSignOutDialog.value) { - ConfirmationDialog( - title = R.string.sign_out_dialog_title, - description = R.string.sign_out_dialog_body, - confirmButtonText = R.string.sign_out, - onConfirmClicked = { homeScreenViewModel.signOut() }, - ) - } - } + @Composable + private fun SetupUserConfirmationDialog() { + val state by homeScreenViewModel.accountDialogState.collectAsStateWithLifecycle() + val user by homeScreenViewModel.user.collectAsStateWithLifecycle(null) + + UserAccountDialogs( + state = state, + user = user, + onSignOut = { homeScreenViewModel.signOut() }, + onShowSignOutConfirmation = { homeScreenViewModel.showSignOutConfirmation() }, + onDismiss = { homeScreenViewModel.dismissLogoutDialog() }, + ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt index 72d82335b3..472b576cf9 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt @@ -20,19 +20,27 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.groundplatform.android.data.sync.MediaUploadWorkManager import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.model.User import org.groundplatform.android.model.submission.DraftSubmission import org.groundplatform.android.repository.MutationRepository import org.groundplatform.android.repository.OfflineAreaRepository import org.groundplatform.android.repository.SubmissionRepository import org.groundplatform.android.repository.SurveyRepository import org.groundplatform.android.repository.UserRepository +import org.groundplatform.android.system.auth.SignInState import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.ui.common.SharedViewModel import timber.log.Timber @@ -56,6 +64,15 @@ internal constructor( private val _openDrawerRequests: MutableSharedFlow = MutableSharedFlow() val openDrawerRequestsFlow: SharedFlow = _openDrawerRequests.asSharedFlow() + private val _accountDialogState = MutableStateFlow(AccountDialogState.HIDDEN) + val accountDialogState: StateFlow = _accountDialogState.asStateFlow() + + val user: Flow = + userRepository + .getSignInState() + .filter { it is SignInState.SignedIn } + .map { (it as SignInState.SignedIn).user } + // TODO: Allow tile source configuration from a non-survey accessible source. // Issue URL: https://github.com/google/ground-android/issues/1730 val showOfflineAreaMenuItem: LiveData = MutableLiveData(true) @@ -120,6 +137,29 @@ internal constructor( suspend fun getOfflineAreas() = offlineAreaRepository.offlineAreas().first() fun signOut() { + _accountDialogState.value = AccountDialogState.HIDDEN viewModelScope.launch { userRepository.signOut() } } + + fun showUserDetails() { + _accountDialogState.value = AccountDialogState.USER_DETAILS + } + + fun showSignOutConfirmation() { + _accountDialogState.value = AccountDialogState.SIGN_OUT_CONFIRMATION + } + + fun dismissLogoutDialog() { + _accountDialogState.value = AccountDialogState.HIDDEN + } + + /** + * Represents the possible visibility states of dialogs related to the user's account, such as + * profile details and sign-out confirmation. + */ + enum class AccountDialogState { + HIDDEN, + USER_DETAILS, + SIGN_OUT_CONFIRMATION, + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt b/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt new file mode 100644 index 0000000000..f466eb399e --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.home + +import androidx.compose.runtime.Composable +import org.groundplatform.android.R +import org.groundplatform.android.model.User +import org.groundplatform.android.ui.components.ConfirmationDialog + +@Composable +fun UserAccountDialogs( + state: HomeScreenViewModel.AccountDialogState, + user: User?, + onSignOut: () -> Unit, + onShowSignOutConfirmation: () -> Unit, + onDismiss: () -> Unit, +) { + when (state) { + HomeScreenViewModel.AccountDialogState.USER_DETAILS -> + user?.let { + UserDetailsDialog( + user = it, + signOutCallback = onShowSignOutConfirmation, + dismissCallback = onDismiss, + ) + } + HomeScreenViewModel.AccountDialogState.SIGN_OUT_CONFIRMATION -> + ConfirmationDialog( + title = R.string.sign_out_dialog_title, + description = R.string.sign_out_dialog_body, + confirmButtonText = R.string.sign_out, + onConfirmClicked = onSignOut, + onDismiss = onDismiss, + ) + else -> {} + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt new file mode 100644 index 0000000000..765d84db7c --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.android.ui.home + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData +import org.groundplatform.android.system.auth.FakeAuthenticationManager +import org.groundplatform.android.system.auth.SignInState +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.HIDDEN +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.SIGN_OUT_CONFIRMATION +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.USER_DETAILS +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class HomeScreenViewModelTest : BaseHiltTest() { + + @Inject lateinit var authenticationManager: FakeAuthenticationManager + @Inject lateinit var viewModel: HomeScreenViewModel + + @Before + override fun setUp() { + super.setUp() + authenticationManager.setUser(FakeData.USER) + authenticationManager.signIn() + } + + @Test + fun testShowUserDetails() { + viewModel.showUserDetails() + + assertThat(viewModel.accountDialogState.value).isEqualTo(USER_DETAILS) + } + + @Test + fun testShowSignOutConfirmation() { + viewModel.showSignOutConfirmation() + + assertThat(viewModel.accountDialogState.value).isEqualTo(SIGN_OUT_CONFIRMATION) + } + + @Test + fun testDismissLogoutDialog() { + viewModel.showUserDetails() + viewModel.dismissLogoutDialog() + + assertThat(viewModel.accountDialogState.value).isEqualTo(HIDDEN) + } + + @Test + fun testSignOut() = runWithTestDispatcher { + viewModel.showSignOutConfirmation() + viewModel.signOut() + + advanceUntilIdle() + + assertThat(viewModel.accountDialogState.value).isEqualTo(HIDDEN) + assertThat(authenticationManager.signInState.filterIsInstance().first()) + .isEqualTo(SignInState.SignedOut) + } + + @Test + fun testAuthenticatedUser() = runWithTestDispatcher { + advanceUntilIdle() + + val user = viewModel.user.filterNotNull().first() + assertThat(user).isEqualTo(FakeData.USER) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt new file mode 100644 index 0000000000..67c1b02b70 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.home + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import org.groundplatform.android.FakeData +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.model.User +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.USER_DETAILS +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserAccountDialogsTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var user: User + + @Before + fun setUp() { + user = FakeData.USER + } + + private fun setupContent( + state: AccountDialogState, + onSignOut: () -> Unit = {}, + onShowSignOutConfirmation: () -> Unit = {}, + ) { + composeTestRule.setContent { + UserAccountDialogs( + state = state, + user = user, + onSignOut = onSignOut, + onShowSignOutConfirmation = onShowSignOutConfirmation, + onDismiss = {}, + ) + } + } + + @Test + fun showUserDetailsDialog() { + setupContent(state = USER_DETAILS) + + composeTestRule.onNodeWithText(user.displayName).assertExists() + composeTestRule.onNodeWithText(user.email).assertExists() + } + + @Test + fun showSignOutConfirmationDialog() { + setupContent(state = AccountDialogState.SIGN_OUT_CONFIRMATION) + + composeTestRule.onNodeWithText(getString(R.string.sign_out_dialog_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.sign_out_dialog_body)).assertIsDisplayed() + } + + @Test + fun clickSignOut_invokesCallback() { + var signOutClicked = false + setupContent( + state = AccountDialogState.SIGN_OUT_CONFIRMATION, + onSignOut = { signOutClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.sign_out)).performClick() + + assertThat(signOutClicked, `is`(true)) + } + + @Test + fun clickSignOutInUserDetails_invokesShowSignOutConfirmation() { + var showSignOutConfirmationClicked = false + setupContent( + state = USER_DETAILS, + onShowSignOutConfirmation = { showSignOutConfirmationClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.sign_out)).performClick() + + assertThat(showSignOutConfirmationClicked, `is`(true)) + } +} From 67a052bc2ecda32439e4e644d8494bc28b1b67e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:28:30 +0530 Subject: [PATCH 09/13] Bump org.mockito:mockito-bom from 5.21.0 to 5.22.0 (#3586) Bumps [org.mockito:mockito-bom](https://github.com/mockito/mockito) from 5.21.0 to 5.22.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.21.0...v5.22.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-bom dependency-version: 5.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a978cee3f..4f3c08dead 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ ktfmtVersion = "0.25.0" lifecycleVersion = "2.10.0" markdownVersion = "0.7.3" materialVersion = "1.13.0" -mockitoBom = "5.21.0" +mockitoBom = "5.22.0" mockitoInlineVersion = "5.2.0" mockitoKotlinVersion = "6.2.3" multidex = "2.0.1" From 9f714c7f94d13dd817db577904a0fefcd55d056d Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Sat, 28 Feb 2026 16:12:10 +0300 Subject: [PATCH 10/13] Rename variable to have a more conventional name --- .../main/java/org/groundplatform/android/e2etest/TestConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index 7ad00c957f..9dc7de787a 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -36,7 +36,7 @@ object TestConfig { TestTask( taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = listOf(1), - isACondition = true, + isConditional = true, ), TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = (0..3).toList()), TestTask(Task.Type.NUMBER), From 89228d361fb419b31f3ceda184b223457d4bd3cf Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Sat, 28 Feb 2026 17:02:50 +0300 Subject: [PATCH 11/13] Create MultipleChoiceType sealed class to represent the kind of multiple choice questions being handled, Refactor DataCollectionRobot to use the new sealed class --- .../android/e2etest/TestTask.kt | 8 +++- .../e2etest/robots/DataCollectionRobot.kt | 41 +++++++++++-------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt index d7281ce2db..a6273f986b 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt @@ -21,5 +21,11 @@ data class TestTask( val taskType: Task.Type, val isRequired: Boolean = false, val selectIndexes: List? = null, - val isACondition: Boolean = false, + val isConditional: Boolean = false, ) + +sealed class MultipleChoiceType { + data class Regular(val selectIndexes: List) : MultipleChoiceType() + + data object Conditional : MultipleChoiceType() +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index a7eec5750b..43b2280765 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.e2etest.robots import org.groundplatform.android.R +import org.groundplatform.android.e2etest.MultipleChoiceType import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT @@ -47,7 +48,13 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot textTask() - Task.Type.MULTIPLE_CHOICE -> multipleChoiceTask(task.selectIndexes!!, task.isACondition) + Task.Type.MULTIPLE_CHOICE -> { + if (task.isConditional) { + multipleChoiceTask(MultipleChoiceType.Conditional) + } else { + multipleChoiceTask(MultipleChoiceType.Regular(task.selectIndexes!!)) + } + } Task.Type.PHOTO -> cameraTask() Task.Type.NUMBER -> numberTask() Task.Type.DATE -> dateTask() @@ -115,26 +122,26 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot, isConditional: Boolean = false) { - when (selectIndexes.size) { - 1 if isConditional -> { - conditionalTask(TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG)) - } - 1 -> { - testDriver.selectFromList( - TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), - selectIndexes[0], - ) - } - else -> { - selectIndexes.forEach { + private fun multipleChoiceTask(multipleChoiceType: MultipleChoiceType) { + when (multipleChoiceType) { + is MultipleChoiceType.Regular -> { + if (multipleChoiceType.selectIndexes.size == 1) { testDriver.selectFromList( - TestDriver.Target.TestTag(SELECT_MULTIPLE_CHECKBOX_TEST_TAG), - it, + TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), + multipleChoiceType.selectIndexes[0], ) + } else { + multipleChoiceType.selectIndexes.forEach { + testDriver.selectFromList( + TestDriver.Target.TestTag(SELECT_MULTIPLE_CHECKBOX_TEST_TAG), + it, + ) + } + testDriver.insertText("Other", TestDriver.Target.TestTag(OTHER_INPUT_TEXT_TEST_TAG)) } - testDriver.insertText("Other", TestDriver.Target.TestTag(OTHER_INPUT_TEXT_TEST_TAG)) } + MultipleChoiceType.Conditional -> + conditionalTask(TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG)) } } From 70b603ce225df4e3e4c805abeaad9775a0fbdea1 Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Sun, 1 Mar 2026 13:07:46 +0300 Subject: [PATCH 12/13] Refactor to rename test constants to more descriptive names to give context about the tests --- .../android/e2etest/TestConfig.kt | 12 +++---- .../e2etest/robots/DataCollectionRobot.kt | 32 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index 9dc7de787a..52d2b97d74 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -49,10 +49,10 @@ object TestConfig { val TEST_LIST_DRAW_AREA = listOf(TestTask(taskType = Task.Type.DRAW_AREA, isRequired = true)) const val LOI_NAME = "Test location" const val TEST_PHOTO_FILE = "e2e_test_photo.webp" - const val ARABICA_TEXT = "Arabica" - const val COFFEE_TEXT = "Coffee" - const val NEXT_BUTTON_TEXT = "Next" - const val PREVIOUS_BUTTON_TEXT = "Previous" - const val COVER_CROPPING_TEXT = "Cover Cropping" - const val PALM_TEXT = "Palm" + const val CONDITIONAL_TRIGGER_OPTION = "Arabica" + const val EXPECTED_CONDITIONAL_OPTION = "Coffee" + const val NEXT_NAVIGATION_TEST_OPTION = "Next" + const val PREVIOUS_NAVIGATION_TEST_OPTION = "Previous" + const val COVER_CROPPING_TEST_OPTION = "Cover Cropping" + const val PALM_TEST_OPTION = "Palm" } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index 43b2280765..77e9b8e9b3 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -17,13 +17,13 @@ package org.groundplatform.android.e2etest.robots import org.groundplatform.android.R import org.groundplatform.android.e2etest.MultipleChoiceType -import org.groundplatform.android.e2etest.TestConfig.ARABICA_TEXT -import org.groundplatform.android.e2etest.TestConfig.COFFEE_TEXT -import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEXT +import org.groundplatform.android.e2etest.TestConfig.CONDITIONAL_TRIGGER_OPTION +import org.groundplatform.android.e2etest.TestConfig.EXPECTED_CONDITIONAL_OPTION +import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEST_OPTION import org.groundplatform.android.e2etest.TestConfig.LOI_NAME -import org.groundplatform.android.e2etest.TestConfig.NEXT_BUTTON_TEXT -import org.groundplatform.android.e2etest.TestConfig.PALM_TEXT -import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_BUTTON_TEXT +import org.groundplatform.android.e2etest.TestConfig.NEXT_NAVIGATION_TEST_OPTION +import org.groundplatform.android.e2etest.TestConfig.PALM_TEST_OPTION +import org.groundplatform.android.e2etest.TestConfig.PREVIOUS_NAVIGATION_TEST_OPTION import org.groundplatform.android.e2etest.TestTask import org.groundplatform.android.e2etest.drivers.TestDriver import org.groundplatform.android.model.task.Task @@ -164,16 +164,16 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot Date: Sun, 1 Mar 2026 13:32:46 +0300 Subject: [PATCH 13/13] Reformat code to pass a code format check --- .../android/e2etest/robots/DataCollectionRobot.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index 77e9b8e9b3..c5d53f31ab 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -18,8 +18,8 @@ package org.groundplatform.android.e2etest.robots import org.groundplatform.android.R import org.groundplatform.android.e2etest.MultipleChoiceType import org.groundplatform.android.e2etest.TestConfig.CONDITIONAL_TRIGGER_OPTION -import org.groundplatform.android.e2etest.TestConfig.EXPECTED_CONDITIONAL_OPTION import org.groundplatform.android.e2etest.TestConfig.COVER_CROPPING_TEST_OPTION +import org.groundplatform.android.e2etest.TestConfig.EXPECTED_CONDITIONAL_OPTION import org.groundplatform.android.e2etest.TestConfig.LOI_NAME import org.groundplatform.android.e2etest.TestConfig.NEXT_NAVIGATION_TEST_OPTION import org.groundplatform.android.e2etest.TestConfig.PALM_TEST_OPTION