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 21e3fe010e..f3c7b57d99 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 @@ -89,15 +89,18 @@ class DrawAreaTaskFragment @Inject constructor() : AbstractTaskFragment + if (isVisible) { + renderComposableDialog { + ConfirmationDialog( + title = R.string.polygon_vertex_add_dialog_title, + description = R.string.polygon_vertex_add_dialog_message, + confirmButtonText = R.string.polygon_vertex_add_dialog_positive_button, + dismissButtonText = null, + onConfirmClicked = { viewModel.dismissSelfIntersectionDialog() }, + onDismiss = { viewModel.dismissSelfIntersectionDialog() }, + ) + } } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index b4bb72a891..2026db123d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -139,17 +139,22 @@ internal constructor( private val _isTooClose = MutableStateFlow(false) val isTooClose: StateFlow = _isTooClose.asStateFlow() - private val _showSelfIntersectionDialog = MutableSharedFlow() - val showSelfIntersectionDialog = _showSelfIntersectionDialog.asSharedFlow() + private val _showSelfIntersectionDialog = MutableStateFlow(false) + val showSelfIntersectionDialog: StateFlow = _showSelfIntersectionDialog.asStateFlow() - var hasSelfIntersection: Boolean = false - private set + private val _hasSelfIntersection = MutableStateFlow(false) + val hasSelfIntersection: StateFlow = _hasSelfIntersection.asStateFlow() + // var hasSelfIntersection: Boolean = false + // private set private lateinit var featureStyle: Feature.Style lateinit var measurementUnits: MeasurementUnits override val taskActionButtonStates: StateFlow> by lazy { - combine(taskTaskData, merge(draftArea, draftUpdates)) { taskData, currentFeature -> + combine(taskTaskData, merge(draftArea, draftUpdates), hasSelfIntersection) { + taskData, + currentFeature, + intersected -> val isClosed = (currentFeature?.geometry as? LineString)?.isClosed() ?: false listOfNotNull( getPreviousButton(), @@ -157,7 +162,7 @@ internal constructor( getUndoButton(taskData, true), getRedoButton(taskData), getAddPointButton(isClosed, isTooClose.value), - getCompleteButton(isClosed, isMarkedComplete.value, hasSelfIntersection), + getCompleteButton(isClosed, isMarkedComplete.value, intersected), getNextButton(taskData).takeIf { isMarkedComplete() }, ) } @@ -203,7 +208,12 @@ internal constructor( @VisibleForTesting fun getLastVertex() = vertices.lastOrNull() private fun onSelfIntersectionDetected() { - viewModelScope.launch { _showSelfIntersectionDialog.emit(Unit) } + _showSelfIntersectionDialog.value = true + } + + fun dismissSelfIntersectionDialog() { + _showSelfIntersectionDialog.value = false + resetHasSelfIntersection() } /** @@ -319,12 +329,12 @@ internal constructor( } private fun checkVertexIntersection(): Boolean { - hasSelfIntersection = isSelfIntersecting(vertices) - if (hasSelfIntersection) { - vertices = vertices.dropLast(1) + _hasSelfIntersection.value = isSelfIntersecting(vertices) + if (_hasSelfIntersection.value) { + updateVertices(vertices.dropLast(1)) onSelfIntersectionDetected() } - return hasSelfIntersection + return _hasSelfIntersection.value } private fun validatePolygonCompletion(): Boolean { @@ -339,8 +349,8 @@ internal constructor( vertices } - hasSelfIntersection = isSelfIntersecting(ring) - if (hasSelfIntersection) { + _hasSelfIntersection.value = isSelfIntersecting(ring) + if (_hasSelfIntersection.value) { onSelfIntersectionDetected() return false } @@ -352,6 +362,10 @@ internal constructor( refreshMap() } + fun resetHasSelfIntersection() { + _hasSelfIntersection.value = false + } + @VisibleForTesting fun completePolygon() { check(LineString(vertices).isClosed()) { "Polygon is not complete" } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 487e429bd6..f53cd47ae3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt @@ -29,12 +29,16 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.model.job.Job @@ -72,6 +76,7 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { override fun setUp() { super.setUp() + Dispatchers.setMain(UnconfinedTestDispatcher()) mergedFeatureFlow = merge(viewModel.draftArea.filterNotNull(), viewModel.draftUpdates) mergedFeatureLiveData = mergedFeatureFlow.asLiveData() @@ -620,6 +625,95 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { assertThat(viewModel.isMarkedComplete()).isTrue() } + @Test + fun `checkVertexIntersection sets hasSelfIntersection and showSelfIntersectionDialog to true`() = + runWithTestDispatcher { + setupViewModel() + + // Create a path: (0,0) -> (10,10) -> (0,10) + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_6) + + // Move cursor to a point that crosses the first segment: (10,0) + updateLastVertex(COORDINATE_5) + + // Trigger ADD_POINT which calls checkVertexIntersection + viewModel.onButtonClick(ButtonAction.ADD_POINT) + advanceUntilIdle() + + assertThat(viewModel.hasSelfIntersection.value).isTrue() + assertThat(viewModel.showSelfIntersectionDialog.value).isTrue() + // offending vertex should be dropped + assertGeometry(4, isLineString = true) + } + + @Test + fun `dismissSelfIntersectionDialog resets both flag and dialog visibility`() = + runWithTestDispatcher { + setupViewModel() + + // Force an intersection state + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_6) + updateLastVertex(COORDINATE_5) + viewModel.onButtonClick(ButtonAction.ADD_POINT) + advanceUntilIdle() + + viewModel.dismissSelfIntersectionDialog() + assertThat(viewModel.hasSelfIntersection.value).isFalse() + assertThat(viewModel.showSelfIntersectionDialog.value).isFalse() + } + + @Test + fun `taskActionButtonStates re-enables COMPLETE button after intersection is dismissed`() = + runTest(UnconfinedTestDispatcher()) { // Use Unconfined to trigger emissions immediately + setupViewModel() + + viewModel.taskActionButtonStates.test { + // 1. Initial State: Create a closed non-intersecting square + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_3) + // Set state to be near first vertex to enable COMPLETE + updateLastVertex(COORDINATE_1, isNearFirstVertex = true) + + // 2. Trigger Intersection Logic + // Clear state (Simulated) + while (viewModel.getLastVertex() != null) { + viewModel.removeLastVertex() + } + + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_6) + updateLastVertexAndAdd(COORDINATE_5) + updateLastVertex(COORDINATE_1, isNearFirstVertex = true) + + // Trigger validation + viewModel.onButtonClick(ButtonAction.COMPLETE) + + // 3. Assert: Button should eventually become disabled due to intersection + // We look for the emission where COMPLETE is disabled + expectMostRecentItem().let { latestStates -> + val completeAction = latestStates.find { it.action == ButtonAction.COMPLETE } + assertThat(completeAction?.isEnabled).isFalse() + } + + // 4. Act: Dismiss and Verify + viewModel.dismissSelfIntersectionDialog() + + // 5. Assert: Button is enabled again + expectMostRecentItem().let { latestStates -> + val completeAction = latestStates.find { it.action == ButtonAction.COMPLETE } + assertThat(completeAction?.isEnabled).isTrue() + } + + cancelAndIgnoreRemainingEvents() + } + } + private fun assertGeometry( expectedVerticesCount: Int, isLineString: Boolean = false, @@ -679,6 +773,8 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { private val COORDINATE_2 = Coordinates(10.0, 10.0) private val COORDINATE_3 = Coordinates(20.0, 20.0) private val COORDINATE_4 = Coordinates(30.0, 30.0) + private val COORDINATE_5 = Coordinates(10.0, 0.0) + private val COORDINATE_6 = Coordinates(0.0, 10.0) private val TASK = Task(