From 8c37721006ff1a9ec2303c522f5b899e329ff3e6 Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Wed, 24 Dec 2025 09:26:24 +0530 Subject: [PATCH 1/3] 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/3] 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/3] 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, + ) } }