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..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}") @@ -115,6 +117,20 @@ 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") + if (geometry is Point) { + DropPinTaskData(geometry) + } else { + DrawGeometryTaskData(geometry!!) + } + } + } 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..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 @@ -19,10 +19,10 @@ 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 -import org.groundplatform.android.proto.Task.DrawGeometry.Method import org.groundplatform.android.proto.Task.TaskTypeCase /** Converts between Firestore nested objects and [Task] instances. */ @@ -50,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.DROP_PIN - } + private fun TaskProto.drawGeometryToTaskType(): Task.Type = Task.Type.DRAW_GEOMETRY fun toTask(task: TaskProto): Task = with(task) { @@ -83,6 +78,16 @@ 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, + 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 new file mode 100644 index 0000000000..ece4d017a7 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/model/task/DrawGeometry.kt @@ -0,0 +1,32 @@ +/* + * 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 + +/** + * 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/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 c7bc9571a6..5cbc1abac8 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 @@ -48,6 +49,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 c05c2a8974..9fab12c325 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 @@ -42,6 +42,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 @@ -368,6 +369,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 -> CaptureLocationTaskViewModel::class.java Task.Type.INSTRUCTIONS -> InstructionTaskViewModel::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 8fffee99c9..f136047418 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 @@ -22,6 +22,7 @@ import dagger.assisted.AssistedInject import javax.inject.Provider 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 @@ -41,11 +42,13 @@ constructor( private val drawAreaTaskFragmentProvider: Provider, private val captureLocationTaskFragmentProvider: Provider, private val dropPinTaskFragmentProvider: Provider, + private val drawGeometryTaskFragmentProvider: Provider, @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] @@ -55,6 +58,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..f60b01b243 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskFragment.kt @@ -0,0 +1,216 @@ +/* + * 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 + +/** + * 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, + 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. + 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() + } + } + + 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() { + 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 { + 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 = 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 new file mode 100644 index 0000000000..00c73605a0 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskMapFragment.kt @@ -0,0 +1,127 @@ +/* + * 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.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() : + 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) } + } + + 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 + } + + 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) + if (taskViewModel.isDrawAreaMode()) { + taskViewModel.onCameraMoved(position.coordinates) + } else { + taskViewModel.updateCameraPosition(position) + } + } + + 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() { + 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 new file mode 100644 index 0000000000..64be188a80 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModel.kt @@ -0,0 +1,428 @@ +/* + * 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.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 +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 +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 +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) + 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 + 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 -> + 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) + viewModelScope.launch { measurementUnits = getUserSettingsUseCase.invoke().measurementUnits } + pinColor = job.getDefaultColor() + featureStyle = Feature.Style(job.getDefaultColor(), Feature.VertexStyle.CIRCLE) + + 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) + } + } + } + } + + 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 + + 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) { + error("Location accuracy $accuracy exceeds threshold $threshold") + } + + val point = Point(location.toCoordinates()) + setValue( + CaptureLocationTaskData( + location = point, + altitude = location.getAltitudeOrNull(), + accuracy = accuracy, + ) + ) + dropMarker(point) + } + } + + fun onDropPin() { + getLastCameraPosition()?.let { + val point = Point(it.coordinates) + 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()) + } + + 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 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 89dad9ef85..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 @@ -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, ) ) } @@ -133,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", @@ -143,8 +147,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, listOf("DROP_PIN")), ), testCase( testLabel = "capture_location", @@ -184,6 +189,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, + ) } } 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..95d7068932 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/geometry/DrawGeometryTaskViewModelTest.kt @@ -0,0 +1,232 @@ +/* + * 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.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.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 +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 + @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() + runWithTestDispatcher { + `when`(getUserSettingsUseCase.invoke()) + .thenReturn(UserSettings("en", MeasurementUnits.METRIC, false)) + } + viewModel = + DrawGeometryTaskViewModel( + uuidGenerator, + localValueStore, + vibrationHelper, + localeAwareMeasureFormatter, + getUserSettingsUseCase, + ) + } + + @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, emptyList()), + ) + 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, emptyList()), + ) + 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, emptyList()), + ) + 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 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") + val task = + Task( + "id", + 0, + Task.Type.DRAW_GEOMETRY, + "label", + false, + drawGeometry = DrawGeometry(false, 10f, emptyList()), + ) + 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 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)) + } +} 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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b20b1588b3..4a8ab1b007 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"