diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/components/TaskMapFragmentContainer.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/TaskMapFragmentContainer.kt new file mode 100644 index 0000000000..f8498449bd --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/TaskMapFragmentContainer.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.datacollection.components + +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentManager +import javax.inject.Provider +import org.groundplatform.android.ui.common.AbstractMapContainerFragment +import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY + +/** + * A Composable that hosts a fragment-based map container for a specific task. + * + * This function bridges the gap between Jetpack Compose and the Fragment-based map implementation + * by using [AndroidView] to embed a [FragmentContainerView]. + */ +@Composable +fun TaskMapFragmentContainer( + taskId: String, + fragmentManager: FragmentManager, + fragmentProvider: Provider, +) { + AndroidView( + factory = { context -> FragmentContainerView(context).apply { id = View.generateViewId() } }, + update = { view -> + with(fragmentProvider.get()) { + arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId)) + fragmentManager.beginTransaction().replace(view.id, this).commit() + } + }, + ) +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreen.kt new file mode 100644 index 0000000000..58a44a2d45 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreen.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.datacollection.tasks + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.components.InstructionData +import org.groundplatform.android.ui.datacollection.components.InstructionsDialog +import org.groundplatform.android.ui.datacollection.components.LoiNameDialog +import org.groundplatform.android.ui.datacollection.components.TaskFooter +import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.components.TaskViewLayout + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TaskScreen( + taskHeader: TaskHeader?, + instructionData: InstructionData?, + taskActionButtonsStates: List, + shouldShowLoiNameDialog: Boolean, + shouldShowHeader: Boolean, + showInstructionsDialog: Boolean, + loiName: String, + onFooterPositionUpdated: (Float) -> Unit, + onAction: (TaskScreenAction) -> Unit, + headerCard: @Composable (() -> Unit)?, + taskBody: @Composable () -> Unit, +) { + val isKeyboardOpen = WindowInsets.isImeVisible + var layoutCoordinates by remember { mutableStateOf(null) } + + // Update footer position whenever layout changes or keyboard is toggled. + LaunchedEffect(isKeyboardOpen, layoutCoordinates) { + layoutCoordinates?.let { onFooterPositionUpdated(it.positionInWindow().y) } + } + + TaskViewLayout( + header = taskHeader, + footer = { + TaskFooter( + modifier = Modifier.onGloballyPositioned { layoutCoordinates = it }, + headerCard = headerCard.takeIf { shouldShowHeader }, + buttonActionStates = taskActionButtonsStates, + onButtonClicked = { onAction(TaskScreenAction.OnButtonClicked(it)) }, + ) + }, + content = { taskBody() }, + ) + + if (shouldShowLoiNameDialog) { + LoiNameDialog( + textFieldValue = loiName, + onConfirmRequest = { onAction(TaskScreenAction.OnLoiNameConfirm(loiName)) }, + onDismissRequest = { onAction(TaskScreenAction.OnLoiNameDismiss) }, + onTextFieldChange = { onAction(TaskScreenAction.OnLoiNameChanged(it)) }, + ) + } + + instructionData + ?.takeIf { showInstructionsDialog } + ?.let { + InstructionsDialog( + data = it, + onDismissed = { onAction(TaskScreenAction.OnInstructionsDismiss) }, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenAction.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenAction.kt new file mode 100644 index 0000000000..d4f9afa109 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenAction.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.datacollection.tasks + +import org.groundplatform.android.ui.datacollection.components.ButtonAction + +/** + * Defines the set of actions that can be triggered from a task-specific screen in the data + * collection flow. + */ +sealed interface TaskScreenAction { + data class OnButtonClicked(val action: ButtonAction) : TaskScreenAction + + data class OnLoiNameConfirm(val name: String) : TaskScreenAction + + data object OnLoiNameDismiss : TaskScreenAction + + data class OnLoiNameChanged(val name: String) : TaskScreenAction + + data object OnInstructionsDismiss : TaskScreenAction +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateInputField.kt similarity index 93% rename from app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreen.kt rename to app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateInputField.kt index 7bff9c5114..5ba60d0f8e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateInputField.kt @@ -41,7 +41,7 @@ const val DATE_TEXT_TEST_TAG: String = "date task input test tag" // TODO: Add trailing icon (close logo) for clearing selected date. @Composable -fun DateTaskScreen( +fun DateInputField( dateText: String, hintText: String, onDateClick: () -> Unit, @@ -72,16 +72,16 @@ fun DateTaskScreen( @Preview(showBackground = true) @Composable @ExcludeFromJacocoGeneratedReport -private fun DateTaskScreenPreview() { +private fun DateInputFieldPreview() { AppTheme { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - DateTaskScreen(dateText = "", hintText = "DD/MM/YYYY", onDateClick = {}) + DateInputField(dateText = "", hintText = "DD/MM/YYYY", onDateClick = {}) Spacer(modifier = Modifier.height(10.dp)) - DateTaskScreen(dateText = "14/02/2026", hintText = "DD/MM/YYYY", onDateClick = {}) + DateInputField(dateText = "14/02/2026", hintText = "DD/MM/YYYY", onDateClick = {}) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskFragment.kt index abdfca03a4..a19e98945f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskFragment.kt @@ -57,7 +57,7 @@ class DateTaskFragment : AbstractTaskFragment() { (DateFormat.getDateFormat(context) as SimpleDateFormat).toPattern().uppercase() } - DateTaskScreen( + DateInputField( modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding), dateText = dateText, hintText = hintText, diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskFragment.kt index 42d3e213fb..184286beac 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskFragment.kt @@ -18,8 +18,6 @@ package org.groundplatform.android.ui.datacollection.tasks.location import android.content.Intent import android.net.Uri import android.provider.Settings -import android.view.View -import android.widget.LinearLayout import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,8 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.os.bundleOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -43,8 +39,8 @@ import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment -import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState @AndroidEntryPoint @@ -61,21 +57,10 @@ class CaptureLocationTaskFragment @Inject constructor() : override fun TaskBody() { var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog - AndroidView( - factory = { context -> - // NOTE(#2493): Multiplying by a random prime to allow for some mathematical uniqueness. - // Otherwise, the sequentially generated ID might conflict with an ID produced by Google - // Maps. - LinearLayout(context).apply { - id = View.generateViewId() * 11149 - val fragment = captureLocationTaskMapFragmentProvider.get() - fragment.arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId)) - childFragmentManager - .beginTransaction() - .add(id, fragment, CaptureLocationTaskMapFragment::class.java.simpleName) - .commit() - } - } + TaskMapFragmentContainer( + taskId = viewModel.task.id, + fragmentManager = childFragmentManager, + fragmentProvider = captureLocationTaskMapFragmentProvider, ) if (showPermissionDeniedDialog) { diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt similarity index 90% rename from app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt rename to app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt index 89b1a8f468..16030e0f9c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt @@ -39,7 +39,7 @@ import org.groundplatform.android.ui.datacollection.components.UriImage import org.groundplatform.ui.theme.AppTheme @Composable -fun PhotoTaskScreen(uri: Uri, onTakePhoto: () -> Unit, modifier: Modifier = Modifier) { +fun PhotoTaskContent(uri: Uri, onTakePhoto: () -> Unit, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp)) { if (uri == Uri.EMPTY) { CaptureButton(onTakePhoto) @@ -68,8 +68,8 @@ private fun CaptureButton(onTakePhoto: () -> Unit) { @Preview(showBackground = true) @Composable @ExcludeFromJacocoGeneratedReport -private fun PhotoTaskScreenPreviewEmpty() { - AppTheme { PhotoTaskScreen(uri = Uri.EMPTY, onTakePhoto = {}) } +private fun PhotoTaskContentPreviewEmpty() { + AppTheme { PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}) } } @Preview(showBackground = true) @@ -77,6 +77,6 @@ private fun PhotoTaskScreenPreviewEmpty() { @ExcludeFromJacocoGeneratedReport private fun PhotoTaskScreenPreviewWithPhoto() { AppTheme { - PhotoTaskScreen(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) + PhotoTaskContent(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt index 9a11ac1d1a..949e566e60 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt @@ -81,7 +81,7 @@ class PhotoTaskFragment : AbstractTaskFragment() { var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog val uri by viewModel.uri.collectAsStateWithLifecycle(Uri.EMPTY) - PhotoTaskScreen( + PhotoTaskContent( modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding), uri = uri, onTakePhoto = { onTakePhoto() }, diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt index be4b3ad1ad..0806025943 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskFragment.kt @@ -15,19 +15,15 @@ */ package org.groundplatform.android.ui.datacollection.tasks.point -import android.view.View -import android.widget.LinearLayout import androidx.compose.runtime.Composable -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.os.bundleOf import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import javax.inject.Provider import org.groundplatform.android.R import org.groundplatform.android.ui.datacollection.components.InstructionData import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment -import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY @AndroidEntryPoint class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment() { @@ -42,18 +38,10 @@ class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment - // NOTE(#2493): Multiplying by a random prime to allow for some mathematical "uniqueness". - // Otherwise, the sequentially generated ID might conflict with an ID produced by Google - // Maps. - LinearLayout(context).apply { - id = View.generateViewId() * 11617 - val fragment = dropPinTaskMapFragmentProvider.get() - fragment.arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId)) - childFragmentManager.beginTransaction().add(id, fragment, "Drop a pin fragment").commit() - } - } + TaskMapFragmentContainer( + taskId = viewModel.task.id, + fragmentManager = childFragmentManager, + fragmentProvider = dropPinTaskMapFragmentProvider, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt index 5543bffffa..09dc2d97b1 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt @@ -15,13 +15,10 @@ */ package org.groundplatform.android.ui.datacollection.tasks.polygon -import android.view.LayoutInflater import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -29,12 +26,11 @@ import javax.inject.Provider import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.groundplatform.android.R -import org.groundplatform.android.databinding.FragmentDrawAreaTaskBinding import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.datacollection.components.InstructionData import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment -import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY @AndroidEntryPoint class DrawAreaTaskFragment @Inject constructor() : AbstractTaskFragment() { @@ -55,29 +51,10 @@ class DrawAreaTaskFragment @Inject constructor() : AbstractTaskFragment - // XML layout is used to provide a static view ID which does not collide with Google Maps - // view ID (https://github.com/google/ground-android/issues/2493). - // The ID is needed when restoring the view on config change since the view is dynamically - // created. - // TODO: Remove this workaround once this UI is migrated to Compose. - // Issue URL: https://github.com/google/ground-android/issues/1795 - val rootView = FragmentDrawAreaTaskBinding.inflate(LayoutInflater.from(context)) - - drawAreaTaskMapFragment = drawAreaTaskMapFragmentProvider.get() - drawAreaTaskMapFragment.arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId)) - childFragmentManager - .beginTransaction() - .add( - R.id.container_draw_area_task_map, - drawAreaTaskMapFragment, - DrawAreaTaskMapFragment::class.java.simpleName, - ) - .commit() - - rootView.root - } + TaskMapFragmentContainer( + taskId = viewModel.task.id, + fragmentManager = childFragmentManager, + fragmentProvider = drawAreaTaskMapFragmentProvider, ) if (showSelfIntersectionDialog) { diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskField.kt similarity index 92% rename from app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreen.kt rename to app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskField.kt index 43b1afec12..d614369242 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskField.kt @@ -41,7 +41,7 @@ const val TIME_TEXT_TEST_TAG: String = "time task input test tag" // TODO: Add trailing icon (close logo) for clearing selected time. @Composable -fun TimeTaskScreen( +fun TimeTaskField( timeText: String, hintText: String, onTimeClick: () -> Unit, @@ -72,16 +72,16 @@ fun TimeTaskScreen( @Preview(showBackground = true) @Composable @ExcludeFromJacocoGeneratedReport -private fun TimeTaskScreenPreview() { +private fun TimeTaskFieldPreview() { AppTheme { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - TimeTaskScreen(timeText = "", hintText = "HH:MM AM", onTimeClick = {}) + TimeTaskField(timeText = "", hintText = "HH:MM AM", onTimeClick = {}) Spacer(modifier = Modifier.height(10.dp)) - TimeTaskScreen(timeText = "10:30 AM", hintText = "HH:MM AM", onTimeClick = {}) + TimeTaskField(timeText = "10:30 AM", hintText = "HH:MM AM", onTimeClick = {}) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskFragment.kt index fde893fcef..4af61d39ec 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskFragment.kt @@ -62,7 +62,7 @@ class TimeTaskFragment : AbstractTaskFragment() { } } - TimeTaskScreen( + TimeTaskField( modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding), timeText = timeText, hintText = hintText, diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt index ddec2b2383..7d29a28cf2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt @@ -153,6 +153,7 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { ) { containerFragment.replaceFragment(containerId, this) getMapAsync { googleMap: GoogleMap -> + if (view == null) return@getMapAsync onMapReady(googleMap) onMapReadyCallback(this) } @@ -227,7 +228,7 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { private fun onMapClick(latLng: LatLng) { val clickedPolygonsOrEmpty = featureManager.getIntersectingPolygons(latLng) - viewLifecycleOwner.lifecycleScope.launch { featureClicks.emit(clickedPolygonsOrEmpty) } + lifecycleScope.launch { featureClicks.emit(clickedPolygonsOrEmpty) } } @SuppressLint("MissingPermission") @@ -267,7 +268,7 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { private fun onCameraMoveStarted(reason: Int) { if (reason == OnCameraMoveStartedListener.REASON_GESTURE) { - viewLifecycleOwner.lifecycleScope.launch { startDragEvents.emit(Unit) } + lifecycleScope.launch { startDragEvents.emit(Unit) } } } diff --git a/app/src/main/res/layout/fragment_draw_area_task.xml b/app/src/main/res/layout/fragment_draw_area_task.xml deleted file mode 100644 index 2ed5f0ef00..0000000000 --- a/app/src/main/res/layout/fragment_draw_area_task.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenTest.kt new file mode 100644 index 0000000000..096fb01948 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenTest.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.datacollection.tasks + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.components.InstructionData +import org.groundplatform.android.ui.datacollection.components.LOI_NAME_TEXT_FIELD_TEST_TAG +import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TaskScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `renders header card when shouldShowHeader is true`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = TaskHeader("Test Header"), + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = true, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = { Text("Header Card Content") }, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText("Header Card Content").assertIsDisplayed() + } + + @Test + fun `renders task body`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = null, + taskBody = { Text("Task Body Content") }, + ) + } + + composeTestRule.onNodeWithText("Task Body Content").assertIsDisplayed() + } + + @Test + fun `renders footer actions and triggers callback`() { + var actionFired: TaskScreenAction? = null + + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = listOf(ButtonActionState(ButtonAction.NEXT)), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = {}, + onAction = { actionFired = it }, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText("Next").performClick() + + assertThat(actionFired).isEqualTo(TaskScreenAction.OnButtonClicked(ButtonAction.NEXT)) + } + + @Test + fun `renders LoiNameDialog when shouldShowLoiNameDialog is true`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = true, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "My Custom LOI", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText("My Custom LOI").assertIsDisplayed() + } + + @Test + fun `triggers LoiNameDialog callbacks`() { + var actionFired: TaskScreenAction? = null + + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = true, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "My Custom LOI", + onFooterPositionUpdated = {}, + onAction = { actionFired = it }, + headerCard = null, + taskBody = {}, + ) + } + + // Trigger explicit callbacks + composeTestRule.onNodeWithText("Save").performClick() + assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameConfirm("My Custom LOI")) + + composeTestRule.onNodeWithText("Cancel").performClick() + assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameDismiss) + + composeTestRule.onNodeWithTag(LOI_NAME_TEXT_FIELD_TEST_TAG).performTextInput("appended ") + assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameChanged("appended My Custom LOI")) + } + + @Test + fun `renders InstructionsDialog and triggers callback`() { + var actionFired: TaskScreenAction? = null + + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = InstructionData(R.drawable.ic_question_answer, R.string.add_point), + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = true, + loiName = "", + onFooterPositionUpdated = {}, + onAction = { actionFired = it }, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText(getString(R.string.add_point)).assertIsDisplayed() + composeTestRule.onNodeWithText("Close").performClick() + + assertThat(actionFired).isEqualTo(TaskScreenAction.OnInstructionsDismiss) + } + + @Test + fun `triggers onFooterPositionUpdated when layout coordinates change`() { + var footerPosition = -1f + + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = { footerPosition = it }, + onAction = {}, + headerCard = null, + taskBody = {}, + ) + } + + // Compose will do a layout pass and call onGloballyPositioned, + // which in turn updates the layoutCoordinates state and triggers LaunchedEffect. + composeTestRule.waitForIdle() + + // Asserts that the callback was fired and a layout coordinate window position was provided. + assertThat(footerPosition).isAtLeast(0f) + } + + @Test + fun `does not render header card when shouldShowHeader is false`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = TaskHeader("Test Header"), + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = { Text("Header Card Content") }, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText("Header Card Content").assertDoesNotExist() + } + + @Test + fun `does not render LoiNameDialog when shouldShowLoiNameDialog is false`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "My Custom LOI", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText("My Custom LOI").assertDoesNotExist() + } + + @Test + fun `does not render InstructionsDialog when showInstructionsDialog is false`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = InstructionData(R.drawable.ic_question_answer, R.string.add_point), + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = false, + loiName = "", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText(getString(R.string.add_point)).assertDoesNotExist() + } + + @Test + fun `does not render InstructionsDialog when instructionData is null`() { + composeTestRule.setContent { + TaskScreen( + taskHeader = null, + instructionData = null, + taskActionButtonsStates = emptyList(), + shouldShowLoiNameDialog = false, + shouldShowHeader = false, + showInstructionsDialog = true, // Expected to show, but data is null + loiName = "", + onFooterPositionUpdated = {}, + onAction = {}, + headerCard = null, + taskBody = {}, + ) + } + + composeTestRule.onNodeWithText(getString(R.string.add_point)).assertDoesNotExist() + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt index da8184e76f..d0af37967b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt @@ -35,7 +35,7 @@ class PhotoTaskScreenTest { @Test fun `shows capture button when photo is not present`() { - composeTestRule.setContent { PhotoTaskScreen(uri = Uri.EMPTY, onTakePhoto = {}) } + composeTestRule.setContent { PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}) } composeTestRule.onNodeWithText("Camera").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Preview").assertIsNotDisplayed() @@ -44,7 +44,7 @@ class PhotoTaskScreenTest { @Test fun `shows photo preview when photo is present`() { composeTestRule.setContent { - PhotoTaskScreen(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) + PhotoTaskContent(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) } composeTestRule.onNodeWithText("Camera").assertIsNotDisplayed() @@ -56,7 +56,7 @@ class PhotoTaskScreenTest { var onTakePhotoCalled = false composeTestRule.setContent { - PhotoTaskScreen(uri = Uri.EMPTY, onTakePhoto = { onTakePhotoCalled = true }) + PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = { onTakePhotoCalled = true }) } composeTestRule.onNodeWithText("Camera").performClick() diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index b4b0e77e9b..31a2a389df 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -3,6 +3,7 @@ CyclomaticComplexMethod:ValueJsonConverter.kt$ValueJsonConverter$fun toResponse(task: Task, obj: Any): TaskData? + LabeledExpression:GoogleMapsFragment.kt$GoogleMapsFragment$@getMapAsync LongMethod:ValueJsonConverter.kt$ValueJsonConverter$fun toResponse(task: Task, obj: Any): TaskData? NestedBlockDepth:SubmissionDataConverter.kt$SubmissionDataConverter$@JvmStatic fun fromString(job: Job, jsonString: String?): SubmissionData ReturnCount:HomeScreenViewModel.kt$HomeScreenViewModel$suspend fun getDraftSubmission(): DraftSubmission?