From 8c37721006ff1a9ec2303c522f5b899e329ff3e6 Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Wed, 24 Dec 2025 09:26:24 +0530 Subject: [PATCH 1/4] added drawGeometryTrask --- .../room/converter/ValueJsonConverter.kt | 11 ++ .../remote/firebase/schema/TaskConverter.kt | 12 +- .../android/model/task/DrawGeometry.kt | 18 ++ .../groundplatform/android/model/task/Task.kt | 2 + .../android/ui/common/ViewModelModule.kt | 6 + .../datacollection/DataCollectionViewModel.kt | 2 + .../DataCollectionViewPagerAdapter.kt | 4 + .../geometry/DrawGeometryTaskFragment.kt | 157 ++++++++++++++++ .../geometry/DrawGeometryTaskMapFragment.kt | 77 ++++++++ .../geometry/DrawGeometryTaskViewModel.kt | 176 ++++++++++++++++++ .../geometry/DrawGeometryTaskViewModelTest.kt | 138 ++++++++++++++ gradle/libs.versions.toml | 2 +- 12 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt index f0b7181e94..64f22f3782 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt @@ -115,6 +115,17 @@ internal object ValueJsonConverter { DataStoreException.checkType(Point::class.java, geometry!!) DropPinTaskData(geometry as Point) } + Task.Type.DRAW_GEOMETRY -> { + if (obj is JSONObject) { + (obj as JSONObject).toCaptureLocationTaskData() + } else { + DataStoreException.checkType(String::class.java, obj) + val geometry = GeometryWrapperTypeConverter.fromString(obj as String)?.getGeometry() + DataStoreException.checkNotNull(geometry, "Missing geometry in draw geometry task result") + DataStoreException.checkType(Point::class.java, geometry!!) + DropPinTaskData(geometry as Point) + } + } Task.Type.CAPTURE_LOCATION -> { DataStoreException.checkType(JSONObject::class.java, obj) (obj as JSONObject).toCaptureLocationTaskData() diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt index 2d1262b899..6d60694454 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt @@ -19,6 +19,7 @@ package org.groundplatform.android.data.remote.firebase.schema import org.groundplatform.android.data.remote.firebase.schema.ConditionConverter.toCondition import org.groundplatform.android.data.remote.firebase.schema.MultipleChoiceConverter.toMultipleChoice import org.groundplatform.android.model.task.Condition +import org.groundplatform.android.model.task.DrawGeometry import org.groundplatform.android.model.task.Task import org.groundplatform.android.proto.Task as TaskProto import org.groundplatform.android.proto.Task.DataCollectionLevel @@ -54,7 +55,7 @@ internal object TaskConverter { if (drawGeometry?.allowedMethodsList?.contains(Method.DRAW_AREA) == true) { Task.Type.DRAW_AREA } else { - Task.Type.DROP_PIN + Task.Type.DRAW_GEOMETRY } fun toTask(task: TaskProto): Task = @@ -83,6 +84,15 @@ internal object TaskConverter { multipleChoice, task.level == DataCollectionLevel.LOI_METADATA, condition = condition, + drawGeometry = + if (taskType == Task.Type.DRAW_GEOMETRY) { + DrawGeometry( + task.drawGeometry.requireDeviceLocation, + task.drawGeometry.minAccuracyMeters, + ) + } else { + null + }, ) } } diff --git a/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt b/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt new file mode 100644 index 0000000000..3b62395a8c --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt @@ -0,0 +1,18 @@ +/* + * 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.model.task + +data class DrawGeometry(val isLocationLockRequired: Boolean, val minAccuracyMeters: Float) diff --git a/app/src/main/java/org/groundplatform/android/model/task/Task.kt b/app/src/main/java/org/groundplatform/android/model/task/Task.kt index e960618930..c10d84b718 100644 --- a/app/src/main/java/org/groundplatform/android/model/task/Task.kt +++ b/app/src/main/java/org/groundplatform/android/model/task/Task.kt @@ -33,6 +33,7 @@ constructor( val multipleChoice: MultipleChoice? = null, val isAddLoiTask: Boolean = false, val condition: Condition? = null, + val drawGeometry: DrawGeometry? = null, ) { // TODO: Define these in data layer! @@ -48,6 +49,7 @@ constructor( TIME, DROP_PIN, DRAW_AREA, + DRAW_GEOMETRY, CAPTURE_LOCATION, INSTRUCTIONS, } diff --git a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt index 79e3dc9f83..700a1bdde8 100644 --- a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt +++ b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt @@ -23,6 +23,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoMap import org.groundplatform.android.ui.datacollection.tasks.date.DateTaskViewModel +import org.groundplatform.android.ui.datacollection.tasks.geometry.DrawGeometryTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.instruction.InstructionTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.location.CaptureLocationTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.MultipleChoiceTaskViewModel @@ -49,6 +50,11 @@ import org.groundplatform.android.ui.tos.TermsOfServiceViewModel @InstallIn(SingletonComponent::class) @Module abstract class ViewModelModule { + @Binds + @IntoMap + @ViewModelKey(DrawGeometryTaskViewModel::class) + abstract fun bindDrawGeometryTaskViewModel(viewModel: DrawGeometryTaskViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(DrawAreaTaskViewModel::class) 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 a9e87320a6..d9c4891570 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 @@ -43,6 +43,7 @@ import org.groundplatform.android.ui.common.EphemeralPopups import org.groundplatform.android.ui.common.ViewModelFactory import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.date.DateTaskViewModel +import org.groundplatform.android.ui.datacollection.tasks.geometry.DrawGeometryTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.instruction.InstructionTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.location.CaptureLocationTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.MultipleChoiceTaskViewModel @@ -373,6 +374,7 @@ internal constructor( Task.Type.DATE -> DateTaskViewModel::class.java Task.Type.TIME -> TimeTaskViewModel::class.java Task.Type.DROP_PIN -> DropPinTaskViewModel::class.java + Task.Type.DRAW_GEOMETRY -> DrawGeometryTaskViewModel::class.java Task.Type.DRAW_AREA -> DrawAreaTaskViewModel::class.java Task.Type.CAPTURE_LOCATION -> if (unifyCaptureLocationTask) DropPinTaskViewModel::class.java diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewPagerAdapter.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewPagerAdapter.kt index fbc07dcb2a..8519ebf1bc 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewPagerAdapter.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewPagerAdapter.kt @@ -23,6 +23,7 @@ import javax.inject.Provider import org.groundplatform.android.UnifyCaptureLocationTask import org.groundplatform.android.model.task.Task import org.groundplatform.android.ui.datacollection.tasks.date.DateTaskFragment +import org.groundplatform.android.ui.datacollection.tasks.geometry.DrawGeometryTaskFragment import org.groundplatform.android.ui.datacollection.tasks.instruction.InstructionTaskFragment import org.groundplatform.android.ui.datacollection.tasks.location.CaptureLocationTaskFragment import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.MultipleChoiceTaskFragment @@ -42,12 +43,14 @@ constructor( private val drawAreaTaskFragmentProvider: Provider, private val captureLocationTaskFragmentProvider: Provider, private val dropPinTaskFragmentProvider: Provider, + private val drawGeometryTaskFragmentProvider: Provider, @UnifyCaptureLocationTask private val unifyCaptureLocationTask: Boolean, @Assisted fragment: Fragment, @Assisted val tasks: List, ) : FragmentStateAdapter(fragment) { override fun getItemCount(): Int = tasks.size + @Suppress("CyclomaticComplexMethod") override fun createFragment(position: Int): Fragment { val task = tasks[position] @@ -57,6 +60,7 @@ constructor( Task.Type.MULTIPLE_CHOICE -> MultipleChoiceTaskFragment() Task.Type.PHOTO -> PhotoTaskFragment() Task.Type.DROP_PIN -> dropPinTaskFragmentProvider.get() + Task.Type.DRAW_GEOMETRY -> drawGeometryTaskFragmentProvider.get() Task.Type.DRAW_AREA -> drawAreaTaskFragmentProvider.get() Task.Type.NUMBER -> NumberTaskFragment() Task.Type.DATE -> DateTaskFragment() diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt new file mode 100644 index 0000000000..5983c49f9e --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt @@ -0,0 +1,157 @@ +/* + * 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.datacollection.tasks.geometry + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.unit.dp +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import javax.inject.Provider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.groundplatform.android.R +import org.groundplatform.android.model.submission.isNullOrEmpty +import org.groundplatform.android.ui.components.ConfirmationDialog +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.InstructionsDialog +import org.groundplatform.android.ui.datacollection.components.TaskView +import org.groundplatform.android.ui.datacollection.components.TaskViewFactory +import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment +import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY +import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState +import org.groundplatform.android.ui.datacollection.tasks.location.LocationAccuracyCard +import org.groundplatform.android.util.renderComposableDialog + +@AndroidEntryPoint +class DrawGeometryTaskFragment @Inject constructor() : + AbstractTaskFragment() { + @Inject lateinit var drawGeometryTaskMapFragmentProvider: Provider + + override fun onCreateTaskView(inflater: LayoutInflater): TaskView = + TaskViewFactory.createWithCombinedHeader(inflater, R.drawable.outline_pin_drop) + + override fun onCreateTaskBody(inflater: LayoutInflater): View { + // NOTE(#2493): Multiplying by a random prime to allow for some mathematical uniqueness. + val rowLayout = LinearLayout(requireContext()).apply { id = View.generateViewId() * 11149 } + val fragment = drawGeometryTaskMapFragmentProvider.get() + val args = Bundle() + args.putString(TASK_ID_FRAGMENT_ARG_KEY, taskId) + fragment.arguments = args + childFragmentManager + .beginTransaction() + .add(rowLayout.id, fragment, DrawGeometryTaskMapFragment::class.java.simpleName) + .commit() + return rowLayout + } + + override fun onTaskResume() { + // Ensure that the location lock is enabled, if it hasn't been. + if (isVisible) { + if (viewModel.isLocationLockRequired()) { + viewModel.enableLocationLock() + lifecycleScope.launch { + viewModel.enableLocationLockFlow.collect { + if (it == LocationLockEnabledState.NEEDS_ENABLE) { + showLocationPermissionDialog() + } + } + } + } else if (viewModel.shouldShowInstructionsDialog()) { + showInstructionsDialog() + } + } + } + + override fun onCreateActionButtons() { + addSkipButton() + addUndoButton() + + if (viewModel.isLocationLockRequired()) { + addButton(ButtonAction.CAPTURE_LOCATION) + .setOnClickListener { viewModel.onCaptureLocation() } + .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } + .apply { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isCaptureEnabled.collect { isEnabled -> enableIfTrue(isEnabled) } + } + } + } else { + addButton(ButtonAction.DROP_PIN) + .setOnClickListener { viewModel.onDropPin() } + .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } + } + + addNextButton(hideIfEmpty = true) + } + + @Composable + override fun HeaderCard() { + if (viewModel.isLocationLockRequired()) { + val location by viewModel.lastLocation.collectAsState() + var showAccuracyCard by remember { mutableStateOf(false) } + + LaunchedEffect(location) { + showAccuracyCard = location != null && !viewModel.isCaptureEnabled.first() + } + + if (showAccuracyCard) { + LocationAccuracyCard( + onDismiss = { showAccuracyCard = false }, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + } + } + + private fun showLocationPermissionDialog() { + renderComposableDialog { + ConfirmationDialog( + title = R.string.allow_location_title, + description = R.string.allow_location_description, + confirmButtonText = R.string.allow_location_confirmation, + onConfirmClicked = { + // Open the app settings + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.fromParts("package", context?.packageName, null) + context?.startActivity(intent) + }, + ) + } + } + + private fun showInstructionsDialog() { + viewModel.instructionsDialogShown = true + renderComposableDialog { + InstructionsDialog(iconId = R.drawable.swipe_24, stringId = R.string.drop_a_pin_tooltip_text) + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt new file mode 100644 index 0000000000..8c8ce4f210 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt @@ -0,0 +1,77 @@ +/* + * 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.datacollection.tasks.geometry + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.launch +import org.groundplatform.android.model.map.CameraPosition +import org.groundplatform.android.ui.common.MapConfig +import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment +import org.groundplatform.android.ui.map.Feature +import org.groundplatform.android.ui.map.MapFragment + +@AndroidEntryPoint +class DrawGeometryTaskMapFragment @Inject constructor() : + AbstractTaskMapFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val root = super.onCreateView(inflater, container, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + getMapViewModel().getLocationUpdates().collect { taskViewModel.updateLocation(it) } + } + return root + } + + override fun getMapConfig(): MapConfig { + val config = super.getMapConfig() + return if (taskViewModel.isLocationLockRequired()) { + config.copy(allowGestures = false) + } else { + config + } + } + + override fun onMapReady(map: MapFragment) { + super.onMapReady(map) + viewLifecycleOwner.lifecycleScope.launch { + taskViewModel.initLocationUpdates(getMapViewModel()) + } + } + + override fun onMapCameraMoved(position: CameraPosition) { + super.onMapCameraMoved(position) + taskViewModel.updateCameraPosition(position) + } + + override fun renderFeatures(): LiveData> = taskViewModel.features + + override fun setDefaultViewPort() { + val feature = taskViewModel.features.value?.firstOrNull() ?: return + val coordinates = feature.geometry.center() + moveToPosition(coordinates) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt new file mode 100644 index 0000000000..0bfc2e3e0b --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt @@ -0,0 +1,176 @@ +/* + * 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.datacollection.tasks.geometry + +import android.location.Location +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.groundplatform.android.common.Constants.ACCURACY_THRESHOLD_IN_M +import org.groundplatform.android.data.local.LocalValueStore +import org.groundplatform.android.data.uuid.OfflineUuidGenerator +import org.groundplatform.android.model.geometry.Point +import org.groundplatform.android.model.job.Job +import org.groundplatform.android.model.job.getDefaultColor +import org.groundplatform.android.model.submission.CaptureLocationTaskData +import org.groundplatform.android.model.submission.DropPinTaskData +import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.tasks.AbstractMapTaskViewModel +import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState +import org.groundplatform.android.ui.map.Feature +import org.groundplatform.android.ui.map.gms.getAccuracyOrNull +import org.groundplatform.android.ui.map.gms.getAltitudeOrNull +import org.groundplatform.android.ui.map.gms.toCoordinates + +class DrawGeometryTaskViewModel +@Inject +constructor( + private val uuidGenerator: OfflineUuidGenerator, + private val localValueStore: LocalValueStore, +) : AbstractMapTaskViewModel() { + + private val _lastLocation = MutableStateFlow(null) + val lastLocation = _lastLocation.asStateFlow() + private var pinColor: Int = 0 + val features: MutableLiveData> = MutableLiveData() + /** Whether the instructions dialog has been shown or not. */ + var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown + + val isCaptureEnabled: Flow = + _lastLocation.map { location -> + val accuracy: Float = location?.getAccuracyOrNull()?.toFloat() ?: Float.MAX_VALUE + location != null && accuracy <= getAccuracyThreshold() + } + + override fun initialize(job: Job, task: Task, taskData: TaskData?) { + super.initialize(job, task, taskData) + pinColor = job.getDefaultColor() + + if (isLocationLockRequired()) { + updateLocationLock(LocationLockEnabledState.ENABLE) + } + + // Drop a marker for current value if Drop Pin mode (or even capture location mode if we want to + // show it?) + // In CaptureLocation, we don't drop a marker. + // In DropPin, we do. + if (!isLocationLockRequired()) { + (taskData as? DropPinTaskData)?.let { dropMarker(it.location) } + } + } + + fun isLocationLockRequired(): Boolean = task.drawGeometry?.isLocationLockRequired ?: false + + private fun getAccuracyThreshold(): Float = + task.drawGeometry?.minAccuracyMeters ?: ACCURACY_THRESHOLD_IN_M.toFloat() + + fun updateLocation(location: Location) { + _lastLocation.update { location } + } + + fun onCaptureLocation() { + val location = _lastLocation.value + if (location == null) { + updateLocationLock(LocationLockEnabledState.ENABLE) + } else { + val accuracy = location.getAccuracyOrNull() + val threshold = getAccuracyThreshold() + if (accuracy != null && accuracy > threshold) { + // Logic to handle poor accuracy? + // CaptureLocationTaskViewModel throws error here, but UI should prevent click. + error("Location accuracy $accuracy exceeds threshold $threshold") + } + + // We save as CaptureLocationTaskData? Or DropPinTaskData? + // Since it's a unified task, we might want to use a unified data type or reuse existing. + // If we use DrawGeometryTaskData, it doesn't exist yet. + // If we use CaptureLocationTaskData, we might break existing DropPin tasks if they convert. + // However, DropPin tasks use DropPinTaskData. + + // For now, let's use DropPinTaskData for everything since DrawGeometry is generalized + // DropPin. + // Use CaptureLocationTaskData if it is strictly capture location? + // Wait, DropPinTaskData is just a point. CaptureLocationTaskData is point + altitude + + // accuracy. + + // If we require device location, we likely want the metadata (accuracy). + // If we drop pin, we just want the point. + + // Let's use CaptureLocationTaskData if location lock is required? + // But DropPin tasks (legacy) used DrawGeometry proto but mapped to DropPin task type. + // Now they are DrawGeometry task type. + + // If I return CaptureLocationTaskData, will it be compatible? + // The task type is DRAW_GEOMETRY. + // Submission data must match task type? + // existing CaptureLocation tasks use CAPTURE_LOCATION type. + // existing DropPin tasks use DROP_PIN type. + // NEW tasks use DRAW_GEOMETRY type. + + // If we use DRAW_GEOMETRY task type, we need a corresponding Submission Data type? + // Or can we re-use? + // TaskData is a sealed class. + // I should check `TaskData` definition. + + setValue( + CaptureLocationTaskData( + location = Point(location.toCoordinates()), + altitude = location.getAltitudeOrNull(), + accuracy = accuracy, + ) + ) + } + } + + fun onDropPin() { + getLastCameraPosition()?.let { + val point = Point(it.coordinates) + setValue(DropPinTaskData(point)) + dropMarker(point) + } + } + + override fun clearResponse() { + super.clearResponse() + features.postValue(setOf()) + } + + private fun dropMarker(point: Point) = + viewModelScope.launch { + val feature = createFeature(point) + features.postValue(setOf(feature)) + } + + /** Creates a new map [Feature] representing the point placed by the user. */ + private suspend fun createFeature(point: Point): Feature = + Feature( + id = uuidGenerator.generateUuid(), + type = Feature.Type.USER_POINT, + geometry = point, + style = Feature.Style(pinColor), + clusterable = false, + selected = true, + ) + + fun shouldShowInstructionsDialog() = !instructionsDialogShown && !isLocationLockRequired() +} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt new file mode 100644 index 0000000000..53cda9da9f --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt @@ -0,0 +1,138 @@ +/* + * 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.datacollection.tasks.geometry + +import android.location.Location +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.data.local.LocalValueStore +import org.groundplatform.android.data.uuid.OfflineUuidGenerator +import org.groundplatform.android.model.geometry.Coordinates +import org.groundplatform.android.model.job.Job +import org.groundplatform.android.model.map.CameraPosition +import org.groundplatform.android.model.submission.CaptureLocationTaskData +import org.groundplatform.android.model.submission.DropPinTaskData +import org.groundplatform.android.model.task.DrawGeometry +import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class DrawGeometryTaskViewModelTest : BaseHiltTest() { + + @Mock lateinit var localValueStore: LocalValueStore + @Mock lateinit var job: Job + @Mock lateinit var uuidGenerator: OfflineUuidGenerator + + private lateinit var viewModel: DrawGeometryTaskViewModel + + @Before + override fun setUp() { + super.setUp() + viewModel = DrawGeometryTaskViewModel(uuidGenerator, localValueStore) + } + + @Test + fun testLocationLockRequired_TaskConfigTrue_ReturnsTrue() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + + val task = + Task("id", 0, Task.Type.DRAW_GEOMETRY, "label", false, drawGeometry = DrawGeometry(true, 10f)) + viewModel.initialize(job, task, null) + + assertThat(viewModel.isLocationLockRequired()).isTrue() + // Should enable location lock + assertThat(viewModel.enableLocationLockFlow.value).isEqualTo(LocationLockEnabledState.ENABLE) + } + + @Test + fun testLocationLockRequired_TaskConfigFalse_ReturnsFalse() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(false, 10f), + ) + viewModel.initialize(job, task, null) + + assertThat(viewModel.isLocationLockRequired()).isFalse() + // Should NOT enable location lock automatically + assertThat(viewModel.enableLocationLockFlow.value).isNotEqualTo(LocationLockEnabledState.ENABLE) + } + + @Test + fun testOnCaptureLocation_UpdatesValue() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(true, 100f), + ) + viewModel.initialize(job, task, null) + + val location = + Location("test").apply { + latitude = 10.0 + longitude = 20.0 + accuracy = 5f + } + viewModel.updateLocation(location) + viewModel.onCaptureLocation() + + val taskData = viewModel.taskTaskData.value as CaptureLocationTaskData + assertThat(taskData.location.coordinates).isEqualTo(Coordinates(10.0, 20.0)) + assertThat(taskData.accuracy).isEqualTo(5.0) + } + + @Test + fun testOnDropPin_UpdatesValue() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(false, 10f), + ) + viewModel.initialize(job, task, null) + + val cameraPosition = CameraPosition(Coordinates(10.0, 20.0), 10f) + viewModel.updateCameraPosition(cameraPosition) + viewModel.onDropPin() + + val taskData = viewModel.taskTaskData.value as DropPinTaskData + assertThat(taskData.location.coordinates).isEqualTo(Coordinates(10.0, 20.0)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d96b83cef..a97d60bfad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ fragmentVersion = "1.8.9" glideVersion = "5.0.5" googleServicesVersion = "4.4.4" gradleVersion = "8.13.2" -groundPlatformVersion = "bc2596d" +groundPlatformVersion = "0f2f688" gsonVersion = "2.13.2" hiltJetpackVersion = "1.3.0" hiltVersion = "2.57.2" From 4faba0d53bee2be0a1eed562e49233b00c5e197d Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Wed, 24 Dec 2025 09:43:16 +0530 Subject: [PATCH 2/4] added the missing DRAW_GEOMETRY --- .../java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt index fc6144f251..7ad4da1cd1 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt @@ -111,6 +111,7 @@ class SurveyRunnerTest : AutomatorRunner { } when (it) { Task.Type.DROP_PIN -> completeDropPinTask() + Task.Type.DRAW_GEOMETRY -> completeDropPinTask() Task.Type.DRAW_AREA -> completeDrawArea() Task.Type.CAPTURE_LOCATION -> completeCaptureLocation() Task.Type.MULTIPLE_CHOICE -> completeMultipleChoice() From 626bcc8ca4f163e3d911ef47986da0a893e67213 Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Wed, 24 Dec 2025 10:14:32 +0530 Subject: [PATCH 3/4] test fix --- .../remote/firebase/schema/TaskConverterTest.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt index 89dad9ef85..f018bbdac3 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt @@ -18,6 +18,7 @@ package org.groundplatform.android.data.remote.firebase.schema import com.google.common.truth.Truth.assertThat import kotlinx.collections.immutable.persistentListOf +import org.groundplatform.android.model.task.DrawGeometry import org.groundplatform.android.model.task.MultipleChoice import org.groundplatform.android.model.task.Task as TaskModel import org.groundplatform.android.model.task.Task.Type @@ -41,6 +42,7 @@ class TaskConverterTest( private val taskType: Type, private val multipleChoice: MultipleChoice?, private val isLoiTask: Boolean, + private val expectedDrawGeometry: DrawGeometry?, ) { @Test @@ -59,6 +61,7 @@ class TaskConverterTest( isRequired = true, multipleChoice = multipleChoice, isAddLoiTask = isLoiTask, + drawGeometry = expectedDrawGeometry, ) ) } @@ -143,8 +146,9 @@ class TaskConverterTest( .setDrawGeometry(drawGeometry { allowedMethods.addAll(listOf(Method.DROP_PIN)) }) .setLevel(Task.DataCollectionLevel.LOI_METADATA) }, - taskType = Type.DROP_PIN, + taskType = Type.DRAW_GEOMETRY, isLoiTask = true, + expectedDrawGeometry = DrawGeometry(false, 0.0f), ), testCase( testLabel = "capture_location", @@ -184,6 +188,15 @@ class TaskConverterTest( taskType: Type, multipleChoice: MultipleChoice? = null, isLoiTask: Boolean = false, - ) = arrayOf(testLabel, protoBuilderLambda, taskType, multipleChoice, isLoiTask) + expectedDrawGeometry: DrawGeometry? = null, + ) = + arrayOf( + testLabel, + protoBuilderLambda, + taskType, + multipleChoice, + isLoiTask, + expectedDrawGeometry, + ) } } From 7f610d32a96038af9c4959c857a6d9150efb30a1 Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Sat, 27 Dec 2025 14:23:03 +0530 Subject: [PATCH 4/4] suggested fixes --- .../room/converter/ValueJsonConverter.kt | 9 +- .../remote/firebase/schema/TaskConverter.kt | 9 +- .../model/submission/GeometryTaskData.kt | 7 +- .../android/model/task/DrawGeometry.kt | 16 +- .../geometry/DrawGeometryTaskFragment.kt | 93 ++++- .../geometry/DrawGeometryTaskMapFragment.kt | 60 ++- .../geometry/DrawGeometryTaskViewModel.kt | 342 +++++++++++++++--- .../room/converter/ValueJsonConverterTest.kt | 20 + .../firebase/schema/TaskConverterTest.kt | 5 +- .../geometry/DrawGeometryTaskViewModelTest.kt | 110 +++++- 10 files changed, 583 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt index 64f22f3782..6808ac244a 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverter.kt @@ -27,6 +27,7 @@ import org.groundplatform.android.model.submission.CaptureLocationTaskData import org.groundplatform.android.model.submission.DateTimeTaskData import org.groundplatform.android.model.submission.DrawAreaTaskData import org.groundplatform.android.model.submission.DrawAreaTaskIncompleteData +import org.groundplatform.android.model.submission.DrawGeometryTaskData import org.groundplatform.android.model.submission.DropPinTaskData import org.groundplatform.android.model.submission.MultipleChoiceTaskData import org.groundplatform.android.model.submission.NumberTaskData @@ -55,6 +56,7 @@ internal object ValueJsonConverter { is DrawAreaTaskData -> GeometryWrapperTypeConverter.toString(taskData.geometry) is DropPinTaskData -> GeometryWrapperTypeConverter.toString(taskData.geometry) is DrawAreaTaskIncompleteData -> GeometryWrapperTypeConverter.toString(taskData.geometry) + is DrawGeometryTaskData -> GeometryWrapperTypeConverter.toString(taskData.geometry) is CaptureLocationTaskData -> taskData.toJSONObject() is SkippedTaskData -> JSONObject().put(SKIPPED_KEY, true) else -> throw UnsupportedOperationException("Unimplemented value class ${taskData.javaClass}") @@ -122,8 +124,11 @@ internal object ValueJsonConverter { DataStoreException.checkType(String::class.java, obj) val geometry = GeometryWrapperTypeConverter.fromString(obj as String)?.getGeometry() DataStoreException.checkNotNull(geometry, "Missing geometry in draw geometry task result") - DataStoreException.checkType(Point::class.java, geometry!!) - DropPinTaskData(geometry as Point) + if (geometry is Point) { + DropPinTaskData(geometry) + } else { + DrawGeometryTaskData(geometry!!) + } } } Task.Type.CAPTURE_LOCATION -> { diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt index 6d60694454..cfda41a51d 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverter.kt @@ -23,7 +23,6 @@ import org.groundplatform.android.model.task.DrawGeometry import org.groundplatform.android.model.task.Task import org.groundplatform.android.proto.Task as TaskProto import org.groundplatform.android.proto.Task.DataCollectionLevel -import org.groundplatform.android.proto.Task.DrawGeometry.Method import org.groundplatform.android.proto.Task.TaskTypeCase /** Converts between Firestore nested objects and [Task] instances. */ @@ -51,12 +50,7 @@ internal object TaskConverter { else -> Task.Type.DATE } - private fun TaskProto.drawGeometryToTaskType(): Task.Type = - if (drawGeometry?.allowedMethodsList?.contains(Method.DRAW_AREA) == true) { - Task.Type.DRAW_AREA - } else { - Task.Type.DRAW_GEOMETRY - } + private fun TaskProto.drawGeometryToTaskType(): Task.Type = Task.Type.DRAW_GEOMETRY fun toTask(task: TaskProto): Task = with(task) { @@ -89,6 +83,7 @@ internal object TaskConverter { DrawGeometry( task.drawGeometry.requireDeviceLocation, task.drawGeometry.minAccuracyMeters, + task.drawGeometry.allowedMethodsList.map { it.name }, ) } else { null diff --git a/app/src/main/java/org/groundplatform/android/model/submission/GeometryTaskData.kt b/app/src/main/java/org/groundplatform/android/model/submission/GeometryTaskData.kt index 5b8a035283..67f836cfc6 100644 --- a/app/src/main/java/org/groundplatform/android/model/submission/GeometryTaskData.kt +++ b/app/src/main/java/org/groundplatform/android/model/submission/GeometryTaskData.kt @@ -22,7 +22,7 @@ import org.groundplatform.android.model.geometry.Polygon import org.groundplatform.android.model.task.Task /** A user-provided response to a geometry-based task ("drop a pin" or "draw an area"). */ -sealed class GeometryTaskData(val geometry: Geometry) : TaskData +sealed class GeometryTaskData(open val geometry: Geometry) : TaskData /** User-provided response to a "drop a pin" data collection [Task]. */ data class DropPinTaskData(val location: Point) : GeometryTaskData(location) { @@ -38,3 +38,8 @@ data class DrawAreaTaskData(val area: Polygon) : GeometryTaskData(area) { data class DrawAreaTaskIncompleteData(val lineString: LineString) : GeometryTaskData(lineString) { override fun isEmpty(): Boolean = lineString.isEmpty() } + +/** User-provided response to a "draw a geometry" data collection [Task]. */ +data class DrawGeometryTaskData(override val geometry: Geometry) : GeometryTaskData(geometry) { + override fun isEmpty(): Boolean = geometry.isEmpty() +} diff --git a/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt b/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt index 3b62395a8c..ece4d017a7 100644 --- a/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt +++ b/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt @@ -15,4 +15,18 @@ */ package org.groundplatform.android.model.task -data class DrawGeometry(val isLocationLockRequired: Boolean, val minAccuracyMeters: Float) +/** + * Suggested configuration for a task that involves drawing geometry on a map. + * + * @property isLocationLockRequired whether the user's location must be locked to the map viewport + * before they can start drawing. + * @property minAccuracyMeters the minimum accuracy (in meters) required for the user's location to + * be considered valid for drawing. + * @property allowedMethods the list of allowed methods for drawing geometry (e.g. "DROP_PIN", + * "DRAW_AREA"). + */ +data class DrawGeometry( + val isLocationLockRequired: Boolean, + val minAccuracyMeters: Float, + val allowedMethods: List, +) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt index 5983c49f9e..f60b01b243 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt @@ -51,13 +51,22 @@ import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledSta import org.groundplatform.android.ui.datacollection.tasks.location.LocationAccuracyCard import org.groundplatform.android.util.renderComposableDialog +/** + * A fragment for displaying and handling the "draw geometry" task. + * + * This task allows the user to define a geometry (e.g. a point, polygon, etc.) on the map. + * Depending on the configuration, it may require the user's location to be locked and accurate. + */ @AndroidEntryPoint class DrawGeometryTaskFragment @Inject constructor() : AbstractTaskFragment() { @Inject lateinit var drawGeometryTaskMapFragmentProvider: Provider override fun onCreateTaskView(inflater: LayoutInflater): TaskView = - TaskViewFactory.createWithCombinedHeader(inflater, R.drawable.outline_pin_drop) + TaskViewFactory.createWithCombinedHeader( + inflater, + if (viewModel.isDrawAreaMode()) R.drawable.outline_draw else R.drawable.outline_pin_drop, + ) override fun onCreateTaskBody(inflater: LayoutInflater): View { // NOTE(#2493): Multiplying by a random prime to allow for some mathematical uniqueness. @@ -89,28 +98,73 @@ class DrawGeometryTaskFragment @Inject constructor() : showInstructionsDialog() } } + + viewModel.polygonArea.observe(viewLifecycleOwner) { area -> + android.widget.Toast.makeText( + requireContext(), + getString(R.string.area_message, area), + android.widget.Toast.LENGTH_LONG, + ) + .show() + } } override fun onCreateActionButtons() { - addSkipButton() - addUndoButton() - - if (viewModel.isLocationLockRequired()) { - addButton(ButtonAction.CAPTURE_LOCATION) - .setOnClickListener { viewModel.onCaptureLocation() } - .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } - .apply { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isCaptureEnabled.collect { isEnabled -> enableIfTrue(isEnabled) } + if (viewModel.isDrawAreaMode()) { + val addPointButton = + addButton(ButtonAction.ADD_POINT).setOnClickListener { + viewModel.addLastVertex() + val intersected = viewModel.checkVertexIntersection() + if (!intersected) viewModel.triggerVibration() + } + val completeButton = + addButton(ButtonAction.COMPLETE) + .setOnClickListener { viewModel.completePolygon() } + .setOnValueChanged { button, _ -> + button.enableIfTrue(viewModel.validatePolygonCompletion()) } + val undoButton = + addButton(ButtonAction.UNDO) + .setOnClickListener { viewModel.removeLastVertex() } + .setOnValueChanged { button, value -> button.enableIfTrue(!value.isNullOrEmpty()) } + val redoButton = + addButton(ButtonAction.REDO) + .setOnClickListener { viewModel.redoLastVertex() } + .setOnValueChanged { button, value -> + button.enableIfTrue(viewModel.redoVertexStack.isNotEmpty() && !value.isNullOrEmpty()) + } + val nextButton = addNextButton(hideIfEmpty = true) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isMarkedComplete.collect { markedComplete -> + addPointButton.showIfTrue(!markedComplete) + completeButton.showIfTrue(!markedComplete) + undoButton.showIfTrue(!markedComplete) + redoButton.showIfTrue(!markedComplete) + nextButton.showIfTrue(markedComplete) } + } } else { - addButton(ButtonAction.DROP_PIN) - .setOnClickListener { viewModel.onDropPin() } - .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } - } + addSkipButton() + addUndoButton() - addNextButton(hideIfEmpty = true) + if (viewModel.isLocationLockRequired()) { + addButton(ButtonAction.CAPTURE_LOCATION) + .setOnClickListener { viewModel.onCaptureLocation() } + .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } + .apply { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isCaptureEnabled.collect { isEnabled -> enableIfTrue(isEnabled) } + } + } + } else { + addButton(ButtonAction.DROP_PIN) + .setOnClickListener { viewModel.onDropPin() } + .setOnValueChanged { button, value -> button.showIfTrue(value.isNullOrEmpty()) } + } + + addNextButton(hideIfEmpty = true) + } } @Composable @@ -151,7 +205,12 @@ class DrawGeometryTaskFragment @Inject constructor() : private fun showInstructionsDialog() { viewModel.instructionsDialogShown = true renderComposableDialog { - InstructionsDialog(iconId = R.drawable.swipe_24, stringId = R.string.drop_a_pin_tooltip_text) + InstructionsDialog( + iconId = if (viewModel.isDrawAreaMode()) R.drawable.touch_app_24 else R.drawable.swipe_24, + stringId = + if (viewModel.isDrawAreaMode()) R.string.draw_area_task_instruction + else R.string.drop_a_pin_tooltip_text, + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt index 8c8ce4f210..00c73605a0 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt @@ -19,16 +19,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.ui.common.MapConfig import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.MapFragment +import org.groundplatform.android.ui.map.gms.GmsExt.toBounds @AndroidEntryPoint class DrawGeometryTaskMapFragment @Inject constructor() : @@ -43,6 +49,32 @@ class DrawGeometryTaskMapFragment @Inject constructor() : viewLifecycleOwner.lifecycleScope.launch { getMapViewModel().getLocationUpdates().collect { taskViewModel.updateLocation(it) } } + + if (taskViewModel.isDrawAreaMode()) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + combine(taskViewModel.isMarkedComplete, taskViewModel.isTooClose) { isComplete, tooClose + -> + !tooClose && !isComplete + } + .collect { shouldShow -> setCenterMarkerVisibility(shouldShow) } + } + + launch { + map.cameraDragEvents.collect { coord -> + if (!taskViewModel.isMarkedComplete()) { + taskViewModel.updateLastVertexAndMaybeCompletePolygon(coord) { c1, c2 -> + map.getDistanceInPixels(c1, c2) + } + } + } + } + + launch { taskViewModel.draftUpdates.collect { map.updateFeature(it) } } + } + } + } return root } @@ -64,14 +96,32 @@ class DrawGeometryTaskMapFragment @Inject constructor() : override fun onMapCameraMoved(position: CameraPosition) { super.onMapCameraMoved(position) - taskViewModel.updateCameraPosition(position) + if (taskViewModel.isDrawAreaMode()) { + taskViewModel.onCameraMoved(position.coordinates) + } else { + taskViewModel.updateCameraPosition(position) + } } - override fun renderFeatures(): LiveData> = taskViewModel.features + override fun renderFeatures(): LiveData> { + if (taskViewModel.isDrawAreaMode()) { + return taskViewModel.draftArea + .map { feature: Feature? -> if (feature == null) setOf() else setOf(feature) } + .asLiveData() + } + return taskViewModel.features + } override fun setDefaultViewPort() { - val feature = taskViewModel.features.value?.firstOrNull() ?: return - val coordinates = feature.geometry.center() - moveToPosition(coordinates) + if (taskViewModel.isDrawAreaMode()) { + val feature = taskViewModel.draftArea.value + val geometry = feature?.geometry ?: return + val bounds = listOf(geometry).toBounds() ?: return + moveToBounds(bounds, padding = 200, shouldAnimate = false) + } else { + val feature = taskViewModel.features.value?.firstOrNull() ?: return + val coordinates = feature.geometry.center() + moveToPosition(coordinates) + } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt index 0bfc2e3e0b..64be188a80 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt @@ -16,11 +16,16 @@ package org.groundplatform.android.ui.datacollection.tasks.geometry import android.location.Location +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -28,10 +33,18 @@ import kotlinx.coroutines.launch import org.groundplatform.android.common.Constants.ACCURACY_THRESHOLD_IN_M import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.uuid.OfflineUuidGenerator +import org.groundplatform.android.model.geometry.Coordinates +import org.groundplatform.android.model.geometry.LineString +import org.groundplatform.android.model.geometry.LinearRing import org.groundplatform.android.model.geometry.Point +import org.groundplatform.android.model.geometry.Polygon import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.getDefaultColor +import org.groundplatform.android.model.settings.MeasurementUnits import org.groundplatform.android.model.submission.CaptureLocationTaskData +import org.groundplatform.android.model.submission.DrawAreaTaskData +import org.groundplatform.android.model.submission.DrawAreaTaskIncompleteData +import org.groundplatform.android.model.submission.DrawGeometryTaskData import org.groundplatform.android.model.submission.DropPinTaskData import org.groundplatform.android.model.submission.TaskData import org.groundplatform.android.model.task.Task @@ -41,12 +54,24 @@ import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.gms.getAccuracyOrNull import org.groundplatform.android.ui.map.gms.getAltitudeOrNull import org.groundplatform.android.ui.map.gms.toCoordinates +import org.groundplatform.android.ui.util.LocaleAwareMeasureFormatter +import org.groundplatform.android.ui.util.VibrationHelper +import org.groundplatform.android.ui.util.calculateShoelacePolygonArea +import org.groundplatform.android.ui.util.getFormattedArea +import org.groundplatform.android.ui.util.isSelfIntersecting +import org.groundplatform.android.usecases.user.GetUserSettingsUseCase +import org.groundplatform.android.util.distanceTo +import org.groundplatform.android.util.penult +import timber.log.Timber class DrawGeometryTaskViewModel @Inject constructor( private val uuidGenerator: OfflineUuidGenerator, private val localValueStore: LocalValueStore, + private val vibrationHelper: VibrationHelper, + private val localeAwareMeasureFormatter: LocaleAwareMeasureFormatter, + private val getUserSettingsUseCase: GetUserSettingsUseCase, ) : AbstractMapTaskViewModel() { private val _lastLocation = MutableStateFlow(null) @@ -54,7 +79,52 @@ constructor( private var pinColor: Int = 0 val features: MutableLiveData> = MutableLiveData() /** Whether the instructions dialog has been shown or not. */ - var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown + var instructionsDialogShown: Boolean + get() = + if (isDrawAreaMode()) { + localValueStore.drawAreaInstructionsShown + } else { + localValueStore.dropPinInstructionsShown + } + set(value) { + if (isDrawAreaMode()) { + localValueStore.drawAreaInstructionsShown = value + } else { + localValueStore.dropPinInstructionsShown = value + } + } + + /** Polygon [Feature] being drawn by the user. */ + private val _draftArea: MutableStateFlow = MutableStateFlow(null) + val draftArea: StateFlow = _draftArea.asStateFlow() + + /** Unique identifier for the currently active draft polygon or line being drawn. */ + private var draftTag: Feature.Tag? = null + + private val _draftUpdates = MutableSharedFlow(extraBufferCapacity = 1) + val draftUpdates = _draftUpdates.asSharedFlow() + + private val _polygonArea = MutableLiveData() + val polygonArea: LiveData = _polygonArea + + private var currentCameraTarget: Coordinates? = null + private var vertices: List = listOf() + private val _redoVertexStack = mutableListOf() + val redoVertexStack: List + get() = _redoVertexStack + + private val _isMarkedComplete = MutableStateFlow(false) + val isMarkedComplete: StateFlow = _isMarkedComplete.asStateFlow() + + private val _isTooClose = MutableStateFlow(false) + val isTooClose: StateFlow = _isTooClose.asStateFlow() + + private val _showSelfIntersectionDialog = MutableSharedFlow() + var hasSelfIntersection: Boolean = false + private set + + private lateinit var featureStyle: Feature.Style + private lateinit var measurementUnits: MeasurementUnits val isCaptureEnabled: Flow = _lastLocation.map { location -> @@ -64,25 +134,67 @@ constructor( override fun initialize(job: Job, task: Task, taskData: TaskData?) { super.initialize(job, task, taskData) + viewModelScope.launch { measurementUnits = getUserSettingsUseCase.invoke().measurementUnits } pinColor = job.getDefaultColor() + featureStyle = Feature.Style(job.getDefaultColor(), Feature.VertexStyle.CIRCLE) - if (isLocationLockRequired()) { - updateLocationLock(LocationLockEnabledState.ENABLE) + if (isDrawAreaMode()) { + initializeDrawArea(taskData) + } else { + initializeDropPin(taskData) + } + } + + private fun initializeDrawArea(taskData: TaskData?) { + if (taskData == null) return + when (taskData) { + is DrawAreaTaskIncompleteData -> { + updateVertices(taskData.lineString.coordinates) + } + is DrawAreaTaskData -> { + updateVertices(taskData.area.getShellCoordinates()) + try { + completePolygon() + } catch (e: IllegalStateException) { + Timber.e(e, "Error when loading draw area from saved state") + updateVertices(listOf()) + } + } + is DrawGeometryTaskData -> { + if (taskData.geometry is Polygon) { + updateVertices(taskData.geometry.getShellCoordinates()) + try { + completePolygon() + } catch (e: IllegalStateException) { + Timber.e(e, "Error when loading draw area from saved state") + updateVertices(listOf()) + } + } else if (taskData.geometry is LineString) { + updateVertices(taskData.geometry.coordinates) + } + } } + } - // Drop a marker for current value if Drop Pin mode (or even capture location mode if we want to - // show it?) - // In CaptureLocation, we don't drop a marker. - // In DropPin, we do. - if (!isLocationLockRequired()) { - (taskData as? DropPinTaskData)?.let { dropMarker(it.location) } + private fun initializeDropPin(taskData: TaskData?) { + val geometry = + (taskData as? DropPinTaskData)?.location + ?: (taskData as? CaptureLocationTaskData)?.location + ?: (taskData as? DrawGeometryTaskData)?.geometry as? Point + + if (geometry != null) { + dropMarker(geometry) + } else if (isLocationLockRequired()) { + updateLocationLock(LocationLockEnabledState.ENABLE) } } + fun isDrawAreaMode(): Boolean = task.drawGeometry?.allowedMethods?.contains("DRAW_AREA") == true + fun isLocationLockRequired(): Boolean = task.drawGeometry?.isLocationLockRequired ?: false private fun getAccuracyThreshold(): Float = - task.drawGeometry?.minAccuracyMeters ?: ACCURACY_THRESHOLD_IN_M.toFloat() + task.drawGeometry?.minAccuracyMeters ?: ACCURACY_THRESHOLD_IN_M fun updateLocation(location: Location) { _lastLocation.update { location } @@ -96,60 +208,181 @@ constructor( val accuracy = location.getAccuracyOrNull() val threshold = getAccuracyThreshold() if (accuracy != null && accuracy > threshold) { - // Logic to handle poor accuracy? - // CaptureLocationTaskViewModel throws error here, but UI should prevent click. error("Location accuracy $accuracy exceeds threshold $threshold") } - // We save as CaptureLocationTaskData? Or DropPinTaskData? - // Since it's a unified task, we might want to use a unified data type or reuse existing. - // If we use DrawGeometryTaskData, it doesn't exist yet. - // If we use CaptureLocationTaskData, we might break existing DropPin tasks if they convert. - // However, DropPin tasks use DropPinTaskData. - - // For now, let's use DropPinTaskData for everything since DrawGeometry is generalized - // DropPin. - // Use CaptureLocationTaskData if it is strictly capture location? - // Wait, DropPinTaskData is just a point. CaptureLocationTaskData is point + altitude + - // accuracy. - - // If we require device location, we likely want the metadata (accuracy). - // If we drop pin, we just want the point. - - // Let's use CaptureLocationTaskData if location lock is required? - // But DropPin tasks (legacy) used DrawGeometry proto but mapped to DropPin task type. - // Now they are DrawGeometry task type. - - // If I return CaptureLocationTaskData, will it be compatible? - // The task type is DRAW_GEOMETRY. - // Submission data must match task type? - // existing CaptureLocation tasks use CAPTURE_LOCATION type. - // existing DropPin tasks use DROP_PIN type. - // NEW tasks use DRAW_GEOMETRY type. - - // If we use DRAW_GEOMETRY task type, we need a corresponding Submission Data type? - // Or can we re-use? - // TaskData is a sealed class. - // I should check `TaskData` definition. - + val point = Point(location.toCoordinates()) setValue( CaptureLocationTaskData( - location = Point(location.toCoordinates()), + location = point, altitude = location.getAltitudeOrNull(), accuracy = accuracy, ) ) + dropMarker(point) } } fun onDropPin() { getLastCameraPosition()?.let { val point = Point(it.coordinates) - setValue(DropPinTaskData(point)) + setValue(DrawGeometryTaskData(point)) dropMarker(point) } } + // Draw Area Methods + fun isMarkedComplete(): Boolean = isMarkedComplete.value + + private fun onSelfIntersectionDetected() { + viewModelScope.launch { _showSelfIntersectionDialog.emit(Unit) } + } + + fun getLastVertex() = vertices.lastOrNull() + + fun removeLastVertex() { + if (vertices.isEmpty()) return + _isMarkedComplete.value = false + _redoVertexStack.add(vertices.last()) + val updatedVertices = vertices.toMutableList().apply { removeAt(lastIndex) }.toImmutableList() + updateVertices(updatedVertices) + if (updatedVertices.isEmpty()) { + setValue(null) + _redoVertexStack.clear() + } else { + setValue(DrawGeometryTaskData(LineString(updatedVertices))) + } + } + + fun redoLastVertex() { + if (redoVertexStack.isEmpty()) { + Timber.e("redoVertexStack is already empty") + return + } + _isMarkedComplete.value = false + val redoVertex = _redoVertexStack.removeAt(_redoVertexStack.lastIndex) + val updatedVertices = vertices.toMutableList().apply { add(redoVertex) }.toImmutableList() + updateVertices(updatedVertices) + setValue(DrawGeometryTaskData(LineString(updatedVertices))) + } + + fun updateLastVertexAndMaybeCompletePolygon( + target: Coordinates, + calculateDistanceInPixels: (c1: Coordinates, c2: Coordinates) -> Double, + ) { + check(!isMarkedComplete.value) { + "Attempted to update last vertex after completing the drawing" + } + + val firstVertex = vertices.firstOrNull() + var updatedTarget = target + if (firstVertex != null && vertices.size > 2) { + val distance = calculateDistanceInPixels(firstVertex, target) + + if (distance <= DISTANCE_THRESHOLD_DP) { + updatedTarget = firstVertex + } + } + + val prev = vertices.dropLast(1).lastOrNull() + _isTooClose.value = + vertices.size > 1 && + prev?.let { calculateDistanceInPixels(it, target) <= DISTANCE_THRESHOLD_DP } == true + + addVertex(updatedTarget, true) + } + + fun onCameraMoved(newTarget: Coordinates) { + currentCameraTarget = newTarget + } + + fun addLastVertex() { + check(!isMarkedComplete.value) { "Attempted to add last vertex after completing the drawing" } + _redoVertexStack.clear() + val vertex = vertices.lastOrNull() ?: currentCameraTarget + vertex?.let { + _isTooClose.value = vertices.size > 1 + addVertex(it, false) + } + } + + private fun addVertex(vertex: Coordinates, shouldOverwriteLastVertex: Boolean) { + val updatedVertices = vertices.toMutableList() + if (shouldOverwriteLastVertex && updatedVertices.isNotEmpty()) { + updatedVertices.removeAt(updatedVertices.lastIndex) + } + updatedVertices.add(vertex) + updateVertices(updatedVertices.toImmutableList()) + + if (!shouldOverwriteLastVertex) { + setValue(DrawGeometryTaskData(LineString(updatedVertices.toImmutableList()))) + } + } + + fun validatePolygonCompletion(): Boolean { + if (vertices.size < 3) return false + val ring = if (vertices.first() != vertices.last()) vertices + vertices.first() else vertices + hasSelfIntersection = isSelfIntersecting(ring) + if (hasSelfIntersection) { + onSelfIntersectionDetected() + return false + } + return true + } + + private fun updateVertices(newVertices: List) { + this.vertices = newVertices + refreshMap() + } + + fun completePolygon() { + check(LineString(vertices).isClosed()) { "Polygon is not complete" } + check(!isMarkedComplete.value) { "Already marked complete" } + + _isMarkedComplete.value = true + + refreshMap() + setValue(DrawGeometryTaskData(Polygon(LinearRing(vertices)))) + + val areaInSquareMeters = calculateShoelacePolygonArea(vertices) + _polygonArea.value = getFormattedArea(areaInSquareMeters, measurementUnits) + } + + private fun refreshMap() = + viewModelScope.launch { + if (vertices.isEmpty()) { + _draftArea.emit(null) + draftTag = null + } else { + if (draftTag == null) { + val feature = buildPolygonFeature() + draftTag = feature.tag + _draftArea.emit(feature) + } else { + val feature = buildPolygonFeature(id = draftTag!!.id) + _draftUpdates.tryEmit(feature) + } + } + } + + private suspend fun buildPolygonFeature(id: String? = null) = + Feature( + id = id ?: uuidGenerator.generateUuid(), + type = Feature.Type.USER_POLYGON, + geometry = LineString(vertices), + style = featureStyle, + clusterable = false, + selected = true, + tooltipText = getDistanceTooltipText(), + ) + + private fun getDistanceTooltipText(): String? { + if (isMarkedComplete.value || vertices.size <= 1) return null + val distance = vertices.penult().distanceTo(vertices.last()) + if (distance < TOOLTIP_MIN_DISTANCE_METERS) return null + return localeAwareMeasureFormatter.formatDistance(distance, measurementUnits) + } + override fun clearResponse() { super.clearResponse() features.postValue(setOf()) @@ -172,5 +405,24 @@ constructor( selected = true, ) + fun checkVertexIntersection(): Boolean { + hasSelfIntersection = isSelfIntersecting(vertices) + if (hasSelfIntersection) { + val updatedVertices = vertices.dropLast(1) + updateVertices(updatedVertices) + onSelfIntersectionDetected() + } + return hasSelfIntersection + } + + fun triggerVibration() { + vibrationHelper.vibrate() + } + fun shouldShowInstructionsDialog() = !instructionsDialogShown && !isLocationLockRequired() + + companion object { + const val DISTANCE_THRESHOLD_DP = 24 + const val TOOLTIP_MIN_DISTANCE_METERS = 0.1 + } } diff --git a/app/src/test/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverterTest.kt index 89884c8b25..12bdf28e08 100644 --- a/app/src/test/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/local/room/converter/ValueJsonConverterTest.kt @@ -28,6 +28,7 @@ import org.groundplatform.android.model.geometry.Polygon import org.groundplatform.android.model.submission.DateTimeTaskData import org.groundplatform.android.model.submission.DrawAreaTaskData import org.groundplatform.android.model.submission.DrawAreaTaskIncompleteData +import org.groundplatform.android.model.submission.DrawGeometryTaskData import org.groundplatform.android.model.submission.DropPinTaskData import org.groundplatform.android.model.submission.MultipleChoiceTaskData import org.groundplatform.android.model.submission.NumberTaskData @@ -113,6 +114,20 @@ class ValueJsonConverterTest( "XQoHcG9seWdvbhJSClAKEgkAAAAAAAAkQBEAAAAAAAA0QAoSCQAAAAAAADRAEQAAAAAAAD5AChIJ\n" + "AAAAAAAAPkARAAAAAAAAREAKEgkAAAAAAAAkQBEAAAAAAAA0QA==\n" + private val drawGeometryTaskResponse = + DrawGeometryTaskData( + Polygon( + LinearRing( + listOf( + Coordinates(10.0, 20.0), + Coordinates(20.0, 30.0), + Coordinates(30.0, 40.0), + Coordinates(10.0, 20.0), + ) + ) + ) + ) + private val incompleteDrawAreaTaskResponse = DrawAreaTaskIncompleteData( LineString( @@ -165,6 +180,11 @@ class ValueJsonConverterTest( incompleteDrawAreaTaskResponse, lineStringGeometryTaskResponseString, ), + arrayOf( + FakeData.newTask(type = Task.Type.DRAW_GEOMETRY), + drawGeometryTaskResponse, + polygonGeometryTaskResponseString, + ), ) } } diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt index f018bbdac3..c7f7475fb9 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TaskConverterTest.kt @@ -136,8 +136,9 @@ class TaskConverterTest( .setDrawGeometry(drawGeometry { allowedMethods.addAll(listOf(Method.DRAW_AREA)) }) .setLevel(Task.DataCollectionLevel.LOI_METADATA) }, - taskType = Type.DRAW_AREA, + taskType = Type.DRAW_GEOMETRY, isLoiTask = true, + expectedDrawGeometry = DrawGeometry(false, 0.0f, listOf("DRAW_AREA")), ), testCase( testLabel = "drop_pin", @@ -148,7 +149,7 @@ class TaskConverterTest( }, taskType = Type.DRAW_GEOMETRY, isLoiTask = true, - expectedDrawGeometry = DrawGeometry(false, 0.0f), + expectedDrawGeometry = DrawGeometry(false, 0.0f, listOf("DROP_PIN")), ), testCase( testLabel = "capture_location", diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt index 53cda9da9f..95d7068932 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt @@ -23,13 +23,19 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.model.geometry.Coordinates +import org.groundplatform.android.model.geometry.Point import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.map.CameraPosition +import org.groundplatform.android.model.settings.MeasurementUnits +import org.groundplatform.android.model.settings.UserSettings import org.groundplatform.android.model.submission.CaptureLocationTaskData -import org.groundplatform.android.model.submission.DropPinTaskData +import org.groundplatform.android.model.submission.DrawGeometryTaskData import org.groundplatform.android.model.task.DrawGeometry import org.groundplatform.android.model.task.Task import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState +import org.groundplatform.android.ui.util.LocaleAwareMeasureFormatter +import org.groundplatform.android.ui.util.VibrationHelper +import org.groundplatform.android.usecases.user.GetUserSettingsUseCase import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -45,13 +51,27 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { @Mock lateinit var localValueStore: LocalValueStore @Mock lateinit var job: Job @Mock lateinit var uuidGenerator: OfflineUuidGenerator + @Mock lateinit var vibrationHelper: VibrationHelper + @Mock lateinit var localeAwareMeasureFormatter: LocaleAwareMeasureFormatter + @Mock lateinit var getUserSettingsUseCase: GetUserSettingsUseCase private lateinit var viewModel: DrawGeometryTaskViewModel @Before override fun setUp() { super.setUp() - viewModel = DrawGeometryTaskViewModel(uuidGenerator, localValueStore) + runWithTestDispatcher { + `when`(getUserSettingsUseCase.invoke()) + .thenReturn(UserSettings("en", MeasurementUnits.METRIC, false)) + } + viewModel = + DrawGeometryTaskViewModel( + uuidGenerator, + localValueStore, + vibrationHelper, + localeAwareMeasureFormatter, + getUserSettingsUseCase, + ) } @Test @@ -59,7 +79,14 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { `when`(uuidGenerator.generateUuid()).thenReturn("uuid") val task = - Task("id", 0, Task.Type.DRAW_GEOMETRY, "label", false, drawGeometry = DrawGeometry(true, 10f)) + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(true, 10f, emptyList()), + ) viewModel.initialize(job, task, null) assertThat(viewModel.isLocationLockRequired()).isTrue() @@ -77,7 +104,7 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { Task.Type.DRAW_GEOMETRY, "label", false, - drawGeometry = DrawGeometry(false, 10f), + drawGeometry = DrawGeometry(false, 10f, emptyList()), ) viewModel.initialize(job, task, null) @@ -96,7 +123,7 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { Task.Type.DRAW_GEOMETRY, "label", false, - drawGeometry = DrawGeometry(true, 100f), + drawGeometry = DrawGeometry(true, 100f, emptyList()), ) viewModel.initialize(job, task, null) @@ -114,6 +141,27 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { assertThat(taskData.accuracy).isEqualTo(5.0) } + @Test + fun testInitialize_WithCaptureLocationTaskData_DropsMarker() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(true, 10f, emptyList()), + ) + val taskData = CaptureLocationTaskData(Point(Coordinates(10.0, 20.0)), null, null) + + viewModel.initialize(job, task, taskData) + + assertThat(viewModel.features.value).hasSize(1) + val feature = viewModel.features.value!!.first() + assertThat(feature.geometry).isEqualTo(Point(Coordinates(10.0, 20.0))) + } + @Test fun testOnDropPin_UpdatesValue() = runWithTestDispatcher { `when`(uuidGenerator.generateUuid()).thenReturn("uuid") @@ -124,7 +172,7 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { Task.Type.DRAW_GEOMETRY, "label", false, - drawGeometry = DrawGeometry(false, 10f), + drawGeometry = DrawGeometry(false, 10f, emptyList()), ) viewModel.initialize(job, task, null) @@ -132,7 +180,53 @@ class DrawGeometryTaskViewModelTest : BaseHiltTest() { viewModel.updateCameraPosition(cameraPosition) viewModel.onDropPin() - val taskData = viewModel.taskTaskData.value as DropPinTaskData - assertThat(taskData.location.coordinates).isEqualTo(Coordinates(10.0, 20.0)) + val taskData = viewModel.taskTaskData.value as DrawGeometryTaskData + // Check points are equal + assertThat((taskData.geometry as org.groundplatform.android.model.geometry.Point).coordinates) + .isEqualTo(Coordinates(10.0, 20.0)) + } + + @Test + fun testIsDrawAreaMode_ReturnsTrue() = runWithTestDispatcher { + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(false, 10f, listOf("DRAW_AREA")), + ) + viewModel.initialize(job, task, null) + + assertThat(viewModel.isDrawAreaMode()).isTrue() + } + + @Test + fun testAddLastVertex_AddsVertexToDrawArea() = runWithTestDispatcher { + `when`(uuidGenerator.generateUuid()).thenReturn("uuid") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(false, 10f, listOf("DRAW_AREA")), + ) + viewModel.initialize(job, task, null) + + // Simulate map drag to P1 + viewModel.updateLastVertexAndMaybeCompletePolygon(Coordinates(10.0, 20.0)) { _, _ -> 100.0 } + // User clicks "Add Point" to commit P1 + viewModel.addLastVertex() + + // Simulate map drag to P2 + viewModel.updateLastVertexAndMaybeCompletePolygon(Coordinates(20.0, 30.0)) { _, _ -> 100.0 } + // User clicks "Add Point" to commit P2 + viewModel.addLastVertex() + + val lastVertex: Coordinates? = viewModel.getLastVertex() + assertThat(lastVertex).isEqualTo(Coordinates(20.0, 30.0)) } }