From e3075d58d013a056b5c684c1ab26df03d9f2ac82 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 14 Apr 2026 09:46:51 +0530 Subject: [PATCH 1/8] refactor: migrate DropPinTask to Jetpack Compose screen and update related tests --- .../tasks/AbstractTaskFragment.kt | 2 +- .../tasks/point/DropPinTaskFragment.kt | 48 +++-- .../tasks/point/DropPinTaskMapFragment.kt | 6 +- .../tasks/point/DropPinTaskScreen.kt | 129 +++++++++++++ .../tasks/point/DropPinTaskViewModel.kt | 11 +- .../tasks/point/DropPinTaskFragmentTest.kt | 136 ------------- .../tasks/point/DropPinTaskScreenTest.kt | 180 ++++++++++++++++++ .../tasks/point/DropPinTaskViewModelTest.kt | 147 -------------- 8 files changed, 342 insertions(+), 317 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt delete mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragmentTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt delete mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt index c09b7ab08e..30e230f4b2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -215,7 +215,7 @@ abstract class AbstractTaskFragment : AbstractFragmen private fun getTask(): Task = viewModel.task @Composable - private fun LoiNameDialog() { + protected fun LoiNameDialog() { var openAlertDialog by dataCollectionViewModel.loiNameDialogOpen if (openAlertDialog) { diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt index 0806025943..5226df7734 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt @@ -15,43 +15,39 @@ */ package org.groundplatform.android.ui.datacollection.tasks.point -import androidx.compose.runtime.Composable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import javax.inject.Provider -import org.groundplatform.android.R -import org.groundplatform.android.ui.datacollection.components.InstructionData -import org.groundplatform.android.ui.datacollection.components.TaskHeader import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment +import org.groundplatform.android.util.createComposeView @AndroidEntryPoint class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment() { @Inject lateinit var dropPinTaskMapFragmentProvider: Provider - override val taskHeader: TaskHeader by lazy { - TaskHeader(viewModel.task.label, R.drawable.outline_pin_drop) - } - - override val instructionData = - InstructionData(iconId = R.drawable.swipe_24, stringId = R.string.drop_a_pin_tooltip_text) - - @Composable - override fun TaskBody() { - TaskMapFragmentContainer( - taskId = viewModel.task.id, - fragmentManager = childFragmentManager, - fragmentProvider = dropPinTaskMapFragmentProvider, - ) - } - - override fun onTaskResume() { - if (isVisible && viewModel.shouldShowInstructionsDialog()) { - viewModel.showInstructionsDialog.value = true + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = createComposeView { + DropPinTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = { saveFooterPosition(it) }, + onAction = { handleTaskScreenAction(it) }, + ) { + TaskMapFragmentContainer( + taskId = viewModel.task.id, + fragmentManager = childFragmentManager, + fragmentProvider = dropPinTaskMapFragmentProvider, + ) } - } - override fun onInstructionDialogDismissed() { - viewModel.instructionsDialogShown = true + if (viewModel.task.isAddLoiTask) { + LoiNameDialog() + } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt index 0dfa7ef9d6..04793dfdfe 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt @@ -16,7 +16,7 @@ package org.groundplatform.android.ui.datacollection.tasks.point import androidx.lifecycle.LiveData -import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.groundplatform.android.model.map.CameraPosition @@ -34,7 +34,7 @@ class DropPinTaskMapFragment @Inject constructor() : // Disable pan/zoom gestures if a marker has been placed on the map. launchWhenTaskVisible(dataCollectionViewModel, taskId) { - taskViewModel.features.asFlow().collect { features -> + taskViewModel.features.collect { features -> updateGestures(features, taskViewModel.captureLocation) } } @@ -53,7 +53,7 @@ class DropPinTaskMapFragment @Inject constructor() : taskViewModel.updateCameraPosition(position) } - override fun renderFeatures(): LiveData> = taskViewModel.features + override fun renderFeatures(): LiveData> = taskViewModel.features.asLiveData() override fun setDefaultViewPort() { val feature = taskViewModel.features.value?.firstOrNull() ?: return diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt new file mode 100644 index 0000000000..f593225a7a --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt @@ -0,0 +1,129 @@ +/* + * 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.datacollection.tasks.point + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.components.InstructionData +import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.tasks.TaskScreen +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.ui.theme.AppTheme + +/** + * A screen for dropping a pin on the map. + * + * This is the stateful wrapper that collects state from [DropPinTaskViewModel] and handles event + * routing. + * + * @param viewModel The view model for this task. + * @param onFooterPositionUpdated Callback when the footer position changes. + * @param onAction Callback for screen actions (e.g., navigation). + * @param mapContent Composable for rendering the map. + */ +@Composable +fun DropPinTaskScreen( + viewModel: DropPinTaskViewModel, + onFooterPositionUpdated: (Float) -> Unit, + onAction: (TaskScreenAction) -> Unit, + mapContent: @Composable () -> Unit, +) { + val taskActionButtonsStates by viewModel.taskActionButtonStates.collectAsStateWithLifecycle() + val showInstructionsDialog = viewModel.showInstructionsDialog.value + + LaunchedEffect(Unit) { + if (viewModel.shouldShowInstructionsDialog()) { + viewModel.showInstructionsDialog.value = true + } + } + + DropPinTaskContent( + taskLabel = viewModel.task.label, + taskActionButtonsStates = taskActionButtonsStates, + showInstructionsDialog = showInstructionsDialog, + onFooterPositionUpdated = onFooterPositionUpdated, + onAction = { action -> + if (action is TaskScreenAction.OnInstructionsDismiss) { + viewModel.instructionsDialogShown = true + viewModel.showInstructionsDialog.value = false + } else { + onAction(action) + } + }, + mapContent = mapContent, + ) +} + +/** + * The stateless content of the drop pin task screen. + * + * @param taskLabel The label of the task. + * @param taskActionButtonsStates The states of the action buttons. + * @param showInstructionsDialog Whether to show the instructions' dialog. + * @param onFooterPositionUpdated Callback when the footer position changes. + * @param onAction Callback for screen actions. + * @param mapContent Composable for rendering the map. + */ +@Composable +private fun DropPinTaskContent( + taskLabel: String, + taskActionButtonsStates: List, + showInstructionsDialog: Boolean, + onFooterPositionUpdated: (Float) -> Unit, + onAction: (TaskScreenAction) -> Unit, + mapContent: @Composable () -> Unit, +) { + TaskScreen( + taskHeader = TaskHeader(taskLabel, R.drawable.outline_pin_drop), + instructionData = + InstructionData(iconId = R.drawable.swipe_24, stringId = R.string.drop_a_pin_tooltip_text), + taskActionButtonsStates = taskActionButtonsStates, + showInstructionsDialog = showInstructionsDialog, + onFooterPositionUpdated = onFooterPositionUpdated, + onAction = onAction, + taskBody = mapContent, + ) +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun DropPinTaskScreenPreview() { + AppTheme { + DropPinTaskContent( + taskLabel = "Task for dropping a pin", + taskActionButtonsStates = + listOf( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), + ), + showInstructionsDialog = true, + onFooterPositionUpdated = {}, + onAction = {}, + mapContent = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt index 0cc059bf29..b30e2fdb2c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt @@ -15,9 +15,11 @@ */ package org.groundplatform.android.ui.datacollection.tasks.point -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.uuid.OfflineUuidGenerator @@ -42,7 +44,8 @@ constructor( ) : AbstractMapTaskViewModel() { private var pinColor: Int = 0 - val features: MutableLiveData> = MutableLiveData() + private val _features = MutableStateFlow>(emptySet()) + val features: StateFlow> = _features.asStateFlow() /** Whether the instructions dialog has been shown or not. */ var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown var captureLocation: Boolean = false @@ -72,7 +75,7 @@ constructor( override fun clearResponse() { super.clearResponse() - features.postValue(setOf()) + _features.value = setOf() } private fun updateResponse(point: Point) { @@ -82,7 +85,7 @@ constructor( private fun dropMarker(point: Point) = viewModelScope.launch { val feature = createFeature(point) - features.postValue(setOf(feature)) + _features.value = setOf(feature) } /** Creates a new map [Feature] representing the point placed by the user. */ diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragmentTest.kt deleted file mode 100644 index 0008771ffc..0000000000 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragmentTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2023 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.datacollection.tasks.point - -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject -import org.groundplatform.android.data.local.LocalValueStore -import org.groundplatform.android.model.map.CameraPosition -import org.groundplatform.android.ui.common.ViewModelFactory -import org.groundplatform.android.ui.datacollection.DataCollectionViewModel -import org.groundplatform.android.ui.datacollection.components.ButtonAction -import org.groundplatform.android.ui.datacollection.components.ButtonActionState -import org.groundplatform.android.ui.datacollection.tasks.BaseTaskFragmentTest -import org.groundplatform.domain.model.geometry.Coordinates -import org.groundplatform.domain.model.geometry.Point -import org.groundplatform.domain.model.job.Job -import org.groundplatform.domain.model.job.Style -import org.groundplatform.domain.model.submission.DropPinTaskData -import org.groundplatform.domain.model.task.Task -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.robolectric.RobolectricTestRunner - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -class DropPinTaskFragmentTest : BaseTaskFragmentTest() { - - @BindValue @Mock override lateinit var dataCollectionViewModel: DataCollectionViewModel - @Inject override lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var localValueStore: LocalValueStore - - private val task = - Task( - id = "task_1", - index = 0, - type = Task.Type.DROP_PIN, - label = "Task for dropping a pin", - isRequired = false, - ) - private val job = Job("job", Style("#112233")) - - @Before - override fun setUp() { - super.setUp() - // Disable the instructions dialog to prevent click jacking. - localValueStore.dropPinInstructionsShown = true - } - - @Test - fun `header renders correctly`() { - setupTaskFragment(job, task) - - hasTaskViewWithoutHeader(task.label) - } - - @Test - fun `drop pin button works`() = runWithTestDispatcher { - val testPosition = CameraPosition(Coordinates(10.0, 20.0)) - setupTaskFragment(job, task) - - viewModel.updateCameraPosition(testPosition) - - runner() - .clickButton("Drop pin") - .assertButtonIsEnabled("Next") - .assertButtonIsEnabled("Undo", true) - .assertButtonIsHidden("Drop pin") - - hasValue(DropPinTaskData(Point(Coordinates(10.0, 20.0)))) - } - - @Test - fun `info card is hidden`() { - setupTaskFragment(job, task) - - runner().assertInfoCardHidden() - } - - @Test - fun `undo works`() = runWithTestDispatcher { - val testPosition = CameraPosition(Coordinates(10.0, 20.0)) - setupTaskFragment(job, task) - - viewModel.updateCameraPosition(testPosition) - - runner() - .clickButton("Drop pin") - .clickButton("Undo", true) - .assertButtonIsHidden("Next") - .assertButtonIsEnabled("Drop pin") - - hasValue(null) - } - - @Test - fun `Initial action buttons state when task is optional`() { - setupTaskFragment(job, task) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), - ) - } - - @Test - fun `Initial action buttons state when task is required`() { - setupTaskFragment(job, task.copy(isRequired = true)) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), - ) - } -} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt new file mode 100644 index 0000000000..d3ace6d8e6 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt @@ -0,0 +1,180 @@ +/* + * 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.datacollection.tasks.point + +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 com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData.JOB +import org.groundplatform.android.FakeData.newTask +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.model.map.CameraPosition +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.tasks.ButtonActionStateChecker +import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.submission.DropPinTaskData +import org.groundplatform.domain.model.submission.TaskData +import org.groundplatform.domain.model.task.Task +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class DropPinTaskScreenTest : BaseHiltTest() { + + @get:Rule val composeTestRule = createComposeRule() + + @Inject lateinit var viewModel: DropPinTaskViewModel + private lateinit var buttonActionStateChecker: ButtonActionStateChecker + private var lastScreenAction: TaskScreenAction? = null + + @Before + override fun setUp() { + super.setUp() + buttonActionStateChecker = ButtonActionStateChecker(composeTestRule) + } + + @Test + fun `displays task correctly`() { + setupTaskScreen(TASK) + + composeTestRule.onNodeWithText(TASK.label).assertIsDisplayed() + } + + @Test + fun `sets initial action buttons state when task is optional`() { + setupTaskScreen(TASK) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), + ) + } + + @Test + fun `sets initial action buttons state when task is required`() { + setupTaskScreen(TASK.copy(isRequired = true)) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), + ) + } + + @Test + fun `drop pin button works`() { + setupTaskScreen(TASK) + viewModel.updateCameraPosition(CameraPosition(Coordinates(10.0, 20.0))) + + buttonActionStateChecker.getNode(ButtonAction.DROP_PIN).performClick() + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.NEXT, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.UNDO, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.DROP_PIN, isEnabled = false, isVisible = false), + ) + + assertThat(viewModel.taskTaskData.value) + .isEqualTo(DropPinTaskData(Point(Coordinates(10.0, 20.0)))) + } + + @Test + fun `undo works`() { + setupTaskScreen(TASK) + viewModel.updateCameraPosition(CameraPosition(Coordinates(10.0, 20.0))) + + buttonActionStateChecker.getNode(ButtonAction.DROP_PIN).performClick() + buttonActionStateChecker.getNode(ButtonAction.UNDO).performClick() + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.DROP_PIN, isEnabled = true, isVisible = true), + ) + + assertThat(viewModel.taskTaskData.value).isNull() + } + + @Test + fun `shows instructions dialog when needed`() { + viewModel.instructionsDialogShown = false + + setupTaskScreen(TASK) + + val tooltipText = getString(R.string.drop_a_pin_tooltip_text) + composeTestRule.onNodeWithText(tooltipText).assertIsDisplayed() + } + + private fun setupTaskScreen( + task: Task, + isFirst: Boolean = false, + isLastWithValue: Boolean = false, + ) { + lastScreenAction = null + viewModel.initialize( + job = JOB, + task = task, + taskData = null, + taskPositionInterface = createTaskPositionInterface(isFirst, isLastWithValue), + surveyId = "survey_id", + ) + + composeTestRule.setContent { + DropPinTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = {}, + onAction = { action -> + lastScreenAction = action + if (action is TaskScreenAction.OnButtonClicked) { + viewModel.onButtonClick(action.action) + } + }, + mapContent = { /* Dummy content */ }, + ) + } + } + + private fun createTaskPositionInterface(isFirst: Boolean, isLastWithValue: Boolean) = + object : TaskPositionInterface { + override fun isFirst() = isFirst + + override fun isLastWithValue(taskData: TaskData?) = isLastWithValue + } + + companion object { + private val TASK = newTask(type = Task.Type.DROP_PIN).copy(label = "Task for dropping a pin") + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt deleted file mode 100644 index d80c7a269b..0000000000 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * 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. - */ -@file:OptIn(ExperimentalCoroutinesApi::class) - -package org.groundplatform.android.ui.datacollection.tasks.point - -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.advanceUntilIdle -import org.groundplatform.android.BaseHiltTest -import org.groundplatform.android.FakeData.JOB -import org.groundplatform.android.FakeData.newTask -import org.groundplatform.android.model.map.CameraPosition -import org.groundplatform.android.ui.datacollection.components.ButtonAction -import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface -import org.groundplatform.domain.model.geometry.Coordinates -import org.groundplatform.domain.model.geometry.Point -import org.groundplatform.domain.model.submission.DropPinTaskData -import org.groundplatform.domain.model.submission.TaskData -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -class DropPinTaskViewModelTest : BaseHiltTest() { - - @Inject lateinit var viewModel: DropPinTaskViewModel - - override fun setUp() { - super.setUp() - setupViewModel() - } - - @Test - fun `Should have the correct action buttons in the proper order`() = runWithTestDispatcher { - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - assertThat(states.map { it.action }) - .containsExactly( - ButtonAction.PREVIOUS, - ButtonAction.SKIP, - ButtonAction.UNDO, - ButtonAction.DROP_PIN, - ButtonAction.NEXT, - ) - .inOrder() - } - - @Test - fun `DROP_PIN is visible and enabled when there's no data yet`() = runWithTestDispatcher { - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - with(requireNotNull(states.find { it.action == ButtonAction.DROP_PIN })) { - assertTrue(isVisible) - assertTrue(isEnabled) - } - } - - @Test - fun `DROP_PIN button is hidden when pin is dropped`() = runWithTestDispatcher { - viewModel.setValue(DropPinTaskData(Point(Coordinates(0.0, 0.0)))) - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - with(requireNotNull(states.find { it.action == ButtonAction.DROP_PIN })) { - assertFalse(isVisible) - } - } - - @Test - fun `UNDO is hidden when there's no data yet`() = runWithTestDispatcher { - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - with(requireNotNull(states.find { it.action == ButtonAction.UNDO })) { assertFalse(isVisible) } - } - - @Test - fun `UNDO is visible and enabled when pin is dropped`() = runWithTestDispatcher { - viewModel.setValue(DropPinTaskData(Point(Coordinates(0.0, 0.0)))) - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - with(requireNotNull(states.find { it.action == ButtonAction.UNDO })) { - assertTrue(isVisible) - assertTrue(isEnabled) - } - } - - @Test - fun `onButtonClick DROP_PIN drops a pin at the current location`() = runWithTestDispatcher { - viewModel.updateCameraPosition(CameraPosition(Coordinates(1.0, 2.0))) - advanceUntilIdle() - - viewModel.onButtonClick(ButtonAction.DROP_PIN) - advanceUntilIdle() - - val data = viewModel.taskTaskData.value as DropPinTaskData - assertThat(data.location.coordinates.lat).isEqualTo(1.0) - assertThat(data.location.coordinates.lng).isEqualTo(2.0) - } - - private fun setupViewModel( - isTaskRequired: Boolean = false, - isFirstTask: Boolean = false, - isLastTaskWithValue: Boolean = false, - ) { - viewModel.initialize( - job = JOB, - task = newTask(isRequired = isTaskRequired), - taskData = null, - taskPositionInterface = - object : TaskPositionInterface { - override fun isFirst(): Boolean = isFirstTask - - override fun isLastWithValue(taskData: TaskData?): Boolean = isLastTaskWithValue - }, - surveyId = "survey_id", - ) - } -} From c03449f33390d6755b5e237fb0cd3dd98ffb23bc Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 14 Apr 2026 10:32:28 +0530 Subject: [PATCH 2/8] refactor: replace mutable state with StateFlow for instructions and simplify map gesture logic --- .../tasks/AbstractTaskFragment.kt | 4 +- .../tasks/AbstractTaskViewModel.kt | 11 +++- .../tasks/point/DropPinTaskMapFragment.kt | 6 +- .../tasks/point/DropPinTaskScreen.kt | 11 +--- .../tasks/point/DropPinTaskViewModel.kt | 61 ++++++++++--------- .../tasks/polygon/DrawAreaTaskFragment.kt | 2 +- 6 files changed, 49 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt index 30e230f4b2..952ebfbe90 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -242,13 +242,13 @@ abstract class AbstractTaskFragment : AbstractFragmen @Composable private fun InstructionsDialog(instructionData: InstructionData) { - var showInstructionsDialog by viewModel.showInstructionsDialog + val showInstructionsDialog by viewModel.showInstructionsDialog.collectAsStateWithLifecycle() if (showInstructionsDialog) { InstructionsDialog( data = instructionData, onDismissed = { - showInstructionsDialog = false + viewModel.dismissInstructions() onInstructionDialogDismissed() }, ) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt index b2a518654a..1a30224b47 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt @@ -43,7 +43,16 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel( private val _taskDataFlow: MutableStateFlow = MutableStateFlow(null) val taskTaskData: StateFlow = _taskDataFlow.asStateFlow() - val showInstructionsDialog = mutableStateOf(false) + private val _showInstructionsDialog = MutableStateFlow(false) + val showInstructionsDialog = _showInstructionsDialog.asStateFlow() + + fun dismissInstructions() { + _showInstructionsDialog.value = false + } + + fun showInstructions() { + _showInstructionsDialog.value = true + } open val taskActionButtonStates: StateFlow> by lazy { taskTaskData diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt index 04793dfdfe..642f4f2a03 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt @@ -35,13 +35,13 @@ class DropPinTaskMapFragment @Inject constructor() : // Disable pan/zoom gestures if a marker has been placed on the map. launchWhenTaskVisible(dataCollectionViewModel, taskId) { taskViewModel.features.collect { features -> - updateGestures(features, taskViewModel.captureLocation) + updateGestures(features) } } } - private fun updateGestures(features: Set, captureLocation: Boolean) { - if (features.isNotEmpty() || captureLocation) { + private fun updateGestures(features: Set) { + if (features.isNotEmpty()) { map.disableGestures() } else { map.enableGestures() diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt index f593225a7a..f1060691b7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt @@ -49,13 +49,7 @@ fun DropPinTaskScreen( mapContent: @Composable () -> Unit, ) { val taskActionButtonsStates by viewModel.taskActionButtonStates.collectAsStateWithLifecycle() - val showInstructionsDialog = viewModel.showInstructionsDialog.value - - LaunchedEffect(Unit) { - if (viewModel.shouldShowInstructionsDialog()) { - viewModel.showInstructionsDialog.value = true - } - } + val showInstructionsDialog by viewModel.showInstructionsDialog.collectAsStateWithLifecycle() DropPinTaskContent( taskLabel = viewModel.task.label, @@ -64,8 +58,7 @@ fun DropPinTaskScreen( onFooterPositionUpdated = onFooterPositionUpdated, onAction = { action -> if (action is TaskScreenAction.OnInstructionsDismiss) { - viewModel.instructionsDialogShown = true - viewModel.showInstructionsDialog.value = false + viewModel.dismissDropPinInstructions() } else { onAction(action) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt index b30e2fdb2c..d4bef7ac18 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt @@ -16,7 +16,6 @@ package org.groundplatform.android.ui.datacollection.tasks.point import androidx.lifecycle.viewModelScope -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,6 +34,7 @@ import org.groundplatform.domain.model.submission.DropPinTaskData import org.groundplatform.domain.model.submission.TaskData import org.groundplatform.domain.model.submission.isNullOrEmpty import org.groundplatform.domain.model.task.Task +import javax.inject.Inject class DropPinTaskViewModel @Inject @@ -46,9 +46,15 @@ constructor( private var pinColor: Int = 0 private val _features = MutableStateFlow>(emptySet()) val features: StateFlow> = _features.asStateFlow() + /** Whether the instructions dialog has been shown or not. */ - var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown - var captureLocation: Boolean = false + internal var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown + + init { + if (!instructionsDialogShown) { + showInstructions() + } + } override fun initialize( job: Job, @@ -61,7 +67,7 @@ constructor( pinColor = job.getDefaultColor() // Drop a marker for current value - (taskData as? DropPinTaskData)?.let { dropMarker(it.location) } + (taskData as? DropPinTaskData)?.let { placeMarker(it.location) } } override fun getButtonStates(taskData: TaskData?): List = @@ -69,7 +75,11 @@ constructor( getPreviousButton(), getSkipButton(taskData), getUndoButton(taskData), - getDropPinButtonState(taskData), + ButtonActionState( + action = ButtonAction.DROP_PIN, + isEnabled = true, + isVisible = taskData.isNullOrEmpty(), + ), getNextButton(taskData, hideIfEmpty = true), ) @@ -78,12 +88,24 @@ constructor( _features.value = setOf() } - private fun updateResponse(point: Point) { - setValue(DropPinTaskData(point)) - dropMarker(point) + override fun onButtonClick(action: ButtonAction) { + if (action == ButtonAction.DROP_PIN) { + getLastCameraPosition()?.let { cameraPosition -> + val point = Point(cameraPosition.coordinates) + setValue(DropPinTaskData(point)) + placeMarker(point) + } + } else { + super.onButtonClick(action) + } + } + + fun dismissDropPinInstructions() { + instructionsDialogShown = true + dismissInstructions() } - private fun dropMarker(point: Point) = viewModelScope.launch { + private fun placeMarker(point: Point) = viewModelScope.launch { val feature = createFeature(point) _features.value = setOf(feature) } @@ -98,25 +120,4 @@ constructor( clusterable = false, selected = true, ) - - private fun dropPin() { - getLastCameraPosition()?.let { updateResponse(Point(it.coordinates)) } - } - - fun shouldShowInstructionsDialog() = !instructionsDialogShown && !captureLocation - - private fun getDropPinButtonState(taskData: TaskData?): ButtonActionState = - ButtonActionState( - action = ButtonAction.DROP_PIN, - isEnabled = true, - isVisible = taskData.isNullOrEmpty(), - ) - - override fun onButtonClick(action: ButtonAction) { - if (action == ButtonAction.DROP_PIN) { - dropPin() - } else { - super.onButtonClick(action) - } - } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt index d6956d1734..67ecfaea23 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt @@ -66,7 +66,7 @@ class DrawAreaTaskFragment @Inject constructor() : AbstractTaskFragment Toast.makeText(requireContext(), getString(R.string.area_message, area), Toast.LENGTH_LONG) From 8e0a3ab0f291b77bea0892213856ce09ec0bb61e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 14 Apr 2026 11:15:26 +0530 Subject: [PATCH 3/8] refactor: integrate LOI name dialog into task action handling flow in DropPinTaskScreen --- .../datacollection/DataCollectionViewModel.kt | 32 +++++++++++++++++-- .../tasks/AbstractTaskFragment.kt | 24 ++++++++++---- .../tasks/AbstractTaskViewModel.kt | 1 - .../tasks/point/DropPinTaskFragment.kt | 17 ++++++---- .../tasks/point/DropPinTaskMapFragment.kt | 4 +-- .../tasks/point/DropPinTaskScreen.kt | 11 ++++++- .../tasks/point/DropPinTaskViewModel.kt | 2 +- .../tasks/point/DropPinTaskScreenTest.kt | 9 ++++-- 8 files changed, 76 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index e46618f150..2e228ed384 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -19,8 +19,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import javax.inject.Provider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -58,6 +56,8 @@ import org.groundplatform.domain.model.submission.isNotNullOrEmpty import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.usecases.GetLoiReportUseCase import timber.log.Timber +import javax.inject.Inject +import javax.inject.Provider /** View model for the Data Collection fragment. */ @HiltViewModel @@ -84,6 +84,10 @@ internal constructor( val uiState: StateFlow = _uiState val loiNameDialogOpen = mutableStateOf(false) + + private val _loiNameDraft = MutableStateFlow("") + val loiNameDraft: StateFlow = _loiNameDraft + private var shouldLoadFromDraft: Boolean = savedStateHandle[TASK_SHOULD_LOAD_FROM_DRAFT] ?: false private val jobId: String = requireNotNull(savedStateHandle[TASK_JOB_ID_KEY]) @@ -126,6 +130,30 @@ internal constructor( } } + fun setLoiNameDraft(name: String) { + _loiNameDraft.value = name + } + + fun getLoiName(): String { + val state = uiState.value + return (state as? DataCollectionUiState.Ready)?.loiName ?: getTypedLoiNameOrEmpty() + } + + fun openLoiNameDialog() { + setLoiNameDraft(getLoiName()) + loiNameDialogOpen.value = true + } + + fun confirmLoiName(name: String) { + loiNameDialogOpen.value = false + setLoiName(name) + } + + fun dismissLoiNameDialog(initialName: String) { + loiNameDialogOpen.value = false + setLoiNameDraft(initialName) + } + private fun isFirstPosition(taskId: String): Boolean = withReadyOrNull { taskSequenceHandler.isFirstPosition(taskId) } ?: false diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt index 952ebfbe90..c889b23d65 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -132,7 +132,7 @@ abstract class AbstractTaskFragment : AbstractFragmen private fun handleNext() { if (getTask().isAddLoiTask) { - dataCollectionViewModel.loiNameDialogOpen.value = true + dataCollectionViewModel.openLoiNameDialog() } else { moveToNext() } @@ -187,11 +187,23 @@ abstract class AbstractTaskFragment : AbstractFragmen /** Handles actions triggered from the task screen UI. */ fun handleTaskScreenAction(screenAction: TaskScreenAction) { - if (screenAction is TaskScreenAction.OnButtonClicked) { - handleButtonClick(screenAction.action) - } else { - // TODO: Handle other actions - // https://github.com/google/ground-android/issues/3630 + when (screenAction) { + is TaskScreenAction.OnButtonClicked -> { + handleButtonClick(screenAction.action) + } + is TaskScreenAction.OnLoiNameChanged -> { + dataCollectionViewModel.setLoiNameDraft(screenAction.name) + } + is TaskScreenAction.OnLoiNameDismiss -> { + dataCollectionViewModel.dismissLoiNameDialog(dataCollectionViewModel.getLoiName()) + } + is TaskScreenAction.OnLoiNameConfirm -> { + dataCollectionViewModel.confirmLoiName(screenAction.name) + moveToNext() + } + else -> { + // Remaining actions are task specific and are handled within the task screen. + } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt index 1a30224b47..9bdd3e286b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt @@ -15,7 +15,6 @@ */ package org.groundplatform.android.ui.datacollection.tasks -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt index 5226df7734..b448de15eb 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt @@ -18,12 +18,14 @@ package org.groundplatform.android.ui.datacollection.tasks.point import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import javax.inject.Provider import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment import org.groundplatform.android.util.createComposeView +import javax.inject.Inject +import javax.inject.Provider @AndroidEntryPoint class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment() { @@ -34,10 +36,15 @@ class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment handleTaskScreenAction(action) }, ) { TaskMapFragmentContainer( taskId = viewModel.task.id, @@ -45,9 +52,5 @@ class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment - updateGestures(features) - } + taskViewModel.features.collect { features -> updateGestures(features) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt index f1060691b7..e82a82045d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreen.kt @@ -16,7 +16,6 @@ package org.groundplatform.android.ui.datacollection.tasks.point import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,6 +44,8 @@ import org.groundplatform.ui.theme.AppTheme fun DropPinTaskScreen( viewModel: DropPinTaskViewModel, onFooterPositionUpdated: (Float) -> Unit, + shouldShowLoiNameDialog: Boolean, + loiName: String, onAction: (TaskScreenAction) -> Unit, mapContent: @Composable () -> Unit, ) { @@ -55,6 +56,8 @@ fun DropPinTaskScreen( taskLabel = viewModel.task.label, taskActionButtonsStates = taskActionButtonsStates, showInstructionsDialog = showInstructionsDialog, + shouldShowLoiNameDialog = shouldShowLoiNameDialog, + loiName = loiName, onFooterPositionUpdated = onFooterPositionUpdated, onAction = { action -> if (action is TaskScreenAction.OnInstructionsDismiss) { @@ -82,6 +85,8 @@ private fun DropPinTaskContent( taskLabel: String, taskActionButtonsStates: List, showInstructionsDialog: Boolean, + shouldShowLoiNameDialog: Boolean, + loiName: String, onFooterPositionUpdated: (Float) -> Unit, onAction: (TaskScreenAction) -> Unit, mapContent: @Composable () -> Unit, @@ -92,6 +97,8 @@ private fun DropPinTaskContent( InstructionData(iconId = R.drawable.swipe_24, stringId = R.string.drop_a_pin_tooltip_text), taskActionButtonsStates = taskActionButtonsStates, showInstructionsDialog = showInstructionsDialog, + shouldShowLoiNameDialog = shouldShowLoiNameDialog, + loiName = loiName, onFooterPositionUpdated = onFooterPositionUpdated, onAction = onAction, taskBody = mapContent, @@ -114,6 +121,8 @@ private fun DropPinTaskScreenPreview() { ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = false), ), showInstructionsDialog = true, + shouldShowLoiNameDialog = false, + loiName = "", onFooterPositionUpdated = {}, onAction = {}, mapContent = {}, diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt index d4bef7ac18..85d1f4c314 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.ui.datacollection.tasks.point import androidx.lifecycle.viewModelScope +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,7 +35,6 @@ import org.groundplatform.domain.model.submission.DropPinTaskData import org.groundplatform.domain.model.submission.TaskData import org.groundplatform.domain.model.submission.isNullOrEmpty import org.groundplatform.domain.model.task.Task -import javax.inject.Inject class DropPinTaskViewModel @Inject diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt index d3ace6d8e6..39d8113b19 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt @@ -142,9 +142,10 @@ class DropPinTaskScreenTest : BaseHiltTest() { task: Task, isFirst: Boolean = false, isLastWithValue: Boolean = false, + viewModelToUse: DropPinTaskViewModel = viewModel, ) { lastScreenAction = null - viewModel.initialize( + viewModelToUse.initialize( job = JOB, task = task, taskData = null, @@ -154,12 +155,14 @@ class DropPinTaskScreenTest : BaseHiltTest() { composeTestRule.setContent { DropPinTaskScreen( - viewModel = viewModel, + viewModel = viewModelToUse, onFooterPositionUpdated = {}, + shouldShowLoiNameDialog = false, + loiName = "", onAction = { action -> lastScreenAction = action if (action is TaskScreenAction.OnButtonClicked) { - viewModel.onButtonClick(action.action) + viewModelToUse.onButtonClick(action.action) } }, mapContent = { /* Dummy content */ }, From 703fa9d1f455aa830461845cb971536da4d80bd3 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 14 Apr 2026 11:33:55 +0530 Subject: [PATCH 4/8] fix imports --- .../android/ui/datacollection/DataCollectionViewModel.kt | 4 ++-- .../ui/datacollection/tasks/point/DropPinTaskFragment.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index 2e228ed384..1c3160cafa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -56,8 +58,6 @@ import org.groundplatform.domain.model.submission.isNotNullOrEmpty import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.usecases.GetLoiReportUseCase import timber.log.Timber -import javax.inject.Inject -import javax.inject.Provider /** View model for the Data Collection fragment. */ @HiltViewModel diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt index b448de15eb..32d0604dd1 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt @@ -21,11 +21,11 @@ import android.view.ViewGroup import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import javax.inject.Provider import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment import org.groundplatform.android.util.createComposeView -import javax.inject.Inject -import javax.inject.Provider @AndroidEntryPoint class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment() { From 8d11ff941fd230f9f3fe727b73a6cedd38a3561c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 14 Apr 2026 11:38:14 +0530 Subject: [PATCH 5/8] Fix detekt issues --- .../tasks/AbstractTaskViewModel.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt index 9bdd3e286b..9d4dcb83d8 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt @@ -45,14 +45,6 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel( private val _showInstructionsDialog = MutableStateFlow(false) val showInstructionsDialog = _showInstructionsDialog.asStateFlow() - fun dismissInstructions() { - _showInstructionsDialog.value = false - } - - fun showInstructions() { - _showInstructionsDialog.value = true - } - open val taskActionButtonStates: StateFlow> by lazy { taskTaskData .map { getButtonStates(it) } @@ -64,6 +56,14 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel( lateinit var task: Task private lateinit var taskPositionInterface: TaskPositionInterface + fun dismissInstructions() { + _showInstructionsDialog.value = false + } + + fun showInstructions() { + _showInstructionsDialog.value = true + } + open fun initialize( job: Job, task: Task, From 0d5c1c2b3a7595591673f0f3967dd1519ba22d8e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 15 Apr 2026 21:31:05 +0530 Subject: [PATCH 6/8] Update `renderFeatures` to return `Flow` instead of `LiveData` --- .../ui/datacollection/tasks/AbstractTaskMapFragment.kt | 9 ++++----- .../datacollection/tasks/point/DropPinTaskMapFragment.kt | 5 ++--- .../tasks/polygon/DrawAreaTaskMapFragment.kt | 6 ++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt index 760c220239..7117e05ffc 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt @@ -27,14 +27,13 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.asFlow import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import java.math.RoundingMode import java.text.DecimalFormat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.common.Constants.ACCURACY_THRESHOLD_IN_M @@ -151,14 +150,14 @@ abstract class AbstractTaskMapFragment : override fun onMapReady(map: MapFragment) { launchWhenTaskVisible(dataCollectionViewModel, taskId) { launch { getMapViewModel().getCurrentCameraPosition().collect { onMapCameraMoved(it) } } - launch { renderFeatures().asFlow().collect { map.setFeatures(it) } } + launch { renderFeatures().collect { map.setFeatures(it) } } // Allow the fragment to restore map viewport to previously drawn feature. setDefaultViewPort() } } /** Must be overridden by subclasses. */ - open fun renderFeatures(): LiveData> = MutableLiveData(setOf()) + open fun renderFeatures(): Flow> = flowOf(setOf()) /** * This should be overridden if the fragment wants to set a custom map camera position. Default diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt index 0205111adb..9bd1a592fe 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskMapFragment.kt @@ -15,10 +15,9 @@ */ package org.groundplatform.android.ui.datacollection.tasks.point -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment import org.groundplatform.android.ui.datacollection.tasks.launchWhenTaskVisible @@ -51,7 +50,7 @@ class DropPinTaskMapFragment @Inject constructor() : taskViewModel.updateCameraPosition(position) } - override fun renderFeatures(): LiveData> = taskViewModel.features.asLiveData() + override fun renderFeatures(): Flow> = taskViewModel.features override fun setDefaultViewPort() { val feature = taskViewModel.features.value?.firstOrNull() ?: return diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt index c0611b0209..0f2ccac4cc 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt @@ -17,10 +17,9 @@ package org.groundplatform.android.ui.datacollection.tasks.polygon import android.os.Bundle import android.view.View -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -70,10 +69,9 @@ class DrawAreaTaskMapFragment @Inject constructor() : moveToBounds(bounds, padding = 200, shouldAnimate = false) } - override fun renderFeatures(): LiveData> = + override fun renderFeatures(): Flow> = taskViewModel.draftArea .map { feature: Feature? -> if (feature == null) setOf() else setOf(feature) } - .asLiveData() override fun onMapCameraMoved(position: CameraPosition) { super.onMapCameraMoved(position) From dc7f38b9db44823d44b23bac14374b3bd1efff85 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 15 Apr 2026 21:35:20 +0530 Subject: [PATCH 7/8] Add missing tests --- .../tasks/point/DropPinTaskScreenTest.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt index 39d8113b19..a74a1a1db3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskScreenTest.kt @@ -27,6 +27,7 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.JOB import org.groundplatform.android.FakeData.newTask import org.groundplatform.android.R +import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.getString import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.ui.datacollection.components.ButtonAction @@ -53,6 +54,7 @@ class DropPinTaskScreenTest : BaseHiltTest() { @get:Rule val composeTestRule = createComposeRule() @Inject lateinit var viewModel: DropPinTaskViewModel + @Inject lateinit var localValueStore: LocalValueStore private lateinit var buttonActionStateChecker: ButtonActionStateChecker private var lastScreenAction: TaskScreenAction? = null @@ -138,17 +140,38 @@ class DropPinTaskScreenTest : BaseHiltTest() { composeTestRule.onNodeWithText(tooltipText).assertIsDisplayed() } + @Test + fun `Initializes with task data`() { + val location = Point(Coordinates(10.0, 20.0)) + val taskData = DropPinTaskData(location) + setupTaskScreen(TASK, taskData = taskData) + + assertThat(viewModel.features.value).hasSize(1) + assertThat(viewModel.features.value.first().geometry).isEqualTo(location) + } + + @Test + fun `dismissDropPinInstructions updates state`() { + setupTaskScreen(TASK) + localValueStore.dropPinInstructionsShown = false + + viewModel.dismissDropPinInstructions() + + assertThat(localValueStore.dropPinInstructionsShown).isTrue() + } + private fun setupTaskScreen( task: Task, isFirst: Boolean = false, isLastWithValue: Boolean = false, + taskData: TaskData? = null, viewModelToUse: DropPinTaskViewModel = viewModel, ) { lastScreenAction = null viewModelToUse.initialize( job = JOB, task = task, - taskData = null, + taskData = taskData, taskPositionInterface = createTaskPositionInterface(isFirst, isLastWithValue), surveyId = "survey_id", ) From e10c212b8b503ad54c6f0613fe791337be6387fe Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 15 Apr 2026 21:35:59 +0530 Subject: [PATCH 8/8] Apply formatting --- .../datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt index 0f2ccac4cc..440179c259 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt @@ -70,8 +70,9 @@ class DrawAreaTaskMapFragment @Inject constructor() : } override fun renderFeatures(): Flow> = - taskViewModel.draftArea - .map { feature: Feature? -> if (feature == null) setOf() else setOf(feature) } + taskViewModel.draftArea.map { feature: Feature? -> + if (feature == null) setOf() else setOf(feature) + } override fun onMapCameraMoved(position: CameraPosition) { super.onMapCameraMoved(position)