From 714304f0be3da3e0d143829d96fa77ac2acf2da2 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 17:32:09 +0530 Subject: [PATCH 01/20] Extract common logic for hosting task map fragments into a new Composable component --- .../components/TaskMapFragmentContainer.kt | 49 +++++++++++++++++++ .../location/CaptureLocationTaskFragment.kt | 25 ++-------- .../tasks/point/DropPinTaskFragment.kt | 22 ++------- .../tasks/polygon/DrawAreaTaskFragment.kt | 33 ++----------- 4 files changed, 64 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/components/TaskMapFragmentContainer.kt 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/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/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) { From 60e6bfd47caf9466efd3d6e40af0c24b4190a184 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 17:36:29 +0530 Subject: [PATCH 02/20] Remove unused resource --- .../res/layout/fragment_draw_area_task.xml | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 app/src/main/res/layout/fragment_draw_area_task.xml 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 @@ - - - - - From c19470db1b5582e358b6fd9393f7e58bf35313a8 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 19:27:35 +0530 Subject: [PATCH 03/20] Rename DateTaskScreen to DateInputField --- .../ui/datacollection/tasks/date/DateTaskFragment.kt | 2 +- .../ui/datacollection/tasks/date/DateTaskScreen.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/date/DateTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreen.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/DateTaskScreen.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 = {}) } } } From f074b40b19601cf869ce2c00c82670c40ce23f1f Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 19:31:41 +0530 Subject: [PATCH 04/20] Rename `PhotoTaskScreen` to `PhotoTaskContent` --- .../ui/datacollection/tasks/photo/PhotoTaskFragment.kt | 2 +- .../ui/datacollection/tasks/photo/PhotoTaskScreen.kt | 8 ++++---- .../ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) 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/photo/PhotoTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.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/PhotoTaskScreen.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/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() From 0752b51ff4d61d6cda8ff2e2f9ed8366232ccc3b Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 19:36:43 +0530 Subject: [PATCH 05/20] Rename task screen components and files to more descriptive names. --- .../tasks/date/{DateTaskScreen.kt => DateInputField.kt} | 0 .../photo/{PhotoTaskScreen.kt => PhotoTaskContent.kt} | 0 .../tasks/time/{TimeTaskScreen.kt => TimeTaskField.kt} | 8 ++++---- .../ui/datacollection/tasks/time/TimeTaskFragment.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/{DateTaskScreen.kt => DateInputField.kt} (100%) rename app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/{PhotoTaskScreen.kt => PhotoTaskContent.kt} (100%) rename app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/{TimeTaskScreen.kt => TimeTaskField.kt} (92%) 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 100% 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 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 100% 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 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, From 1d36a3e560735be4a9a55b5e4ef847ee6ef7a132 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 19:54:31 +0530 Subject: [PATCH 06/20] Introduce `TaskScreen` Composable for data collection tasks --- .../ui/datacollection/tasks/TaskScreen.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreen.kt 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..155a9b39ba --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreen.kt @@ -0,0 +1,84 @@ +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.saveable.rememberSaveable +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.ButtonAction +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 +import org.groundplatform.domain.model.task.Task + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TaskScreen( + task: Task, + taskHeader: TaskHeader?, + instructionData: InstructionData?, + taskActionButtonsStates: List, + loiNameDialogOpen: Boolean, + shouldShowHeader: Boolean, + showInstructionsDialog: Boolean, + initialNameValue: String, + onFooterPositionUpdated: (Float) -> Unit, + onButtonClicked: (ButtonAction) -> Unit, + onLoiNameConfirm: (String) -> Unit, + onLoiNameDismiss: () -> Unit, + onInstructionsDismiss: () -> 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 = onButtonClicked, + ) + }, + content = { taskBody() }, + ) + + if (task.isAddLoiTask && loiNameDialogOpen) { + val nameState = rememberSaveable { mutableStateOf(initialNameValue) } + + LoiNameDialog( + textFieldValue = nameState.value, + onConfirmRequest = { onLoiNameConfirm(nameState.value) }, + onDismissRequest = { + nameState.value = initialNameValue + onLoiNameDismiss() + }, + onTextFieldChange = { nameState.value = it }, + ) + } + + instructionData + ?.takeIf { showInstructionsDialog } + ?.let { InstructionsDialog(data = it, onDismissed = onInstructionsDismiss) } +} From 127c562dab1e64c308b7406736ca2d3227ed68c7 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 20:07:35 +0530 Subject: [PATCH 07/20] Extract TaskScreen state into a dedicated state holder class --- .../ui/datacollection/tasks/TaskScreen.kt | 39 ++++++++------- .../datacollection/tasks/TaskScreenState.kt | 47 +++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt 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 index 155a9b39ba..7c3184c4e9 100644 --- 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 @@ -1,3 +1,18 @@ +/* + * 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 @@ -5,13 +20,7 @@ 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.saveable.rememberSaveable -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.ButtonAction @@ -42,20 +51,20 @@ fun TaskScreen( onInstructionsDismiss: () -> Unit, headerCard: @Composable (() -> Unit)?, taskBody: @Composable () -> Unit, + state: TaskScreenState = rememberTaskScreenState(initialLoiName = initialNameValue) ) { 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) } + LaunchedEffect(isKeyboardOpen, state.layoutCoordinates) { + state.layoutCoordinates?.let { onFooterPositionUpdated(it.positionInWindow().y) } } TaskViewLayout( header = taskHeader, footer = { TaskFooter( - modifier = Modifier.onGloballyPositioned { layoutCoordinates = it }, + modifier = Modifier.onGloballyPositioned { state.layoutCoordinates = it }, headerCard = headerCard.takeIf { shouldShowHeader }, buttonActionStates = taskActionButtonsStates, onButtonClicked = onButtonClicked, @@ -65,16 +74,14 @@ fun TaskScreen( ) if (task.isAddLoiTask && loiNameDialogOpen) { - val nameState = rememberSaveable { mutableStateOf(initialNameValue) } - LoiNameDialog( - textFieldValue = nameState.value, - onConfirmRequest = { onLoiNameConfirm(nameState.value) }, + textFieldValue = state.loiName, + onConfirmRequest = { onLoiNameConfirm(state.loiName) }, onDismissRequest = { - nameState.value = initialNameValue + state.setLoiName(initialNameValue) onLoiNameDismiss() }, - onTextFieldChange = { nameState.value = it }, + onTextFieldChange = { state.loiName = it }, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt new file mode 100644 index 0000000000..56ca123465 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt @@ -0,0 +1,47 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.layout.LayoutCoordinates + +/** State holder for the [TaskScreen] composable. */ +@Stable +class TaskScreenState(initialLoiName: String) { + var layoutCoordinates by mutableStateOf(null) + + var loiName by mutableStateOf(initialLoiName) + + fun setLoiName(newName: String) { + loiName = newName + } + + companion object { + fun saver(): Saver = + Saver(save = { it.loiName }, restore = { TaskScreenState(it) }) + } +} + +@Composable +fun rememberTaskScreenState(initialLoiName: String): TaskScreenState { + return rememberSaveable(saver = TaskScreenState.saver()) { TaskScreenState(initialLoiName) } +} From 358825e116fb95f8364996fa90197dc046008ca1 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 21:56:18 +0530 Subject: [PATCH 08/20] Remove setLoiName function from TaskScreenState Removed the setLoiName function to simplify state management. --- .../android/ui/datacollection/tasks/TaskScreenState.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt index 56ca123465..c808f18b2a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt @@ -31,10 +31,6 @@ class TaskScreenState(initialLoiName: String) { var loiName by mutableStateOf(initialLoiName) - fun setLoiName(newName: String) { - loiName = newName - } - companion object { fun saver(): Saver = Saver(save = { it.loiName }, restore = { TaskScreenState(it) }) From 4bd575ff19f5823d779e0e0261b8d9eda0e858fa Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 27 Mar 2026 22:00:29 +0530 Subject: [PATCH 09/20] Refactor loiName state assignment in TaskScreen --- .../android/ui/datacollection/tasks/TaskScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7c3184c4e9..a7741a690c 100644 --- 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 @@ -78,7 +78,7 @@ fun TaskScreen( textFieldValue = state.loiName, onConfirmRequest = { onLoiNameConfirm(state.loiName) }, onDismissRequest = { - state.setLoiName(initialNameValue) + state.loiName = initialNameValue onLoiNameDismiss() }, onTextFieldChange = { state.loiName = it }, From 98991effb7edf1293f34c09a1f499d618ed3bded Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sat, 28 Mar 2026 10:20:12 +0530 Subject: [PATCH 10/20] Refactor TaskScreenState to use remember instead of rememberSaveable --- .../android/ui/datacollection/tasks/TaskScreen.kt | 2 +- .../ui/datacollection/tasks/TaskScreenState.kt | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) 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 index a7741a690c..2c1c80a35a 100644 --- 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 @@ -51,7 +51,7 @@ fun TaskScreen( onInstructionsDismiss: () -> Unit, headerCard: @Composable (() -> Unit)?, taskBody: @Composable () -> Unit, - state: TaskScreenState = rememberTaskScreenState(initialLoiName = initialNameValue) + state: TaskScreenState = rememberTaskScreenState(initialLoiName = initialNameValue), ) { val isKeyboardOpen = WindowInsets.isImeVisible diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt index c808f18b2a..d6d9f68427 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt @@ -19,8 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.layout.LayoutCoordinates @@ -30,14 +29,9 @@ class TaskScreenState(initialLoiName: String) { var layoutCoordinates by mutableStateOf(null) var loiName by mutableStateOf(initialLoiName) - - companion object { - fun saver(): Saver = - Saver(save = { it.loiName }, restore = { TaskScreenState(it) }) - } } @Composable -fun rememberTaskScreenState(initialLoiName: String): TaskScreenState { - return rememberSaveable(saver = TaskScreenState.saver()) { TaskScreenState(initialLoiName) } +fun rememberTaskScreenState(initialLoiName: String): TaskScreenState = remember { + TaskScreenState(initialLoiName) } From e4e71c5b94421842855acfd209741a1bfd56a39c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:15:50 +0530 Subject: [PATCH 11/20] Apply suggestions. Make TaskScreen stateless --- .../ui/datacollection/tasks/TaskScreen.kt | 58 ++++++++++++------- .../datacollection/tasks/TaskScreenState.kt | 37 ------------ 2 files changed, 36 insertions(+), 59 deletions(-) delete mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt 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 index 2c1c80a35a..d3200ba1f9 100644 --- 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 @@ -20,7 +20,12 @@ 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.ButtonAction @@ -31,61 +36,70 @@ 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 -import org.groundplatform.domain.model.task.Task + +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 +} @OptIn(ExperimentalLayoutApi::class) @Composable fun TaskScreen( - task: Task, taskHeader: TaskHeader?, instructionData: InstructionData?, taskActionButtonsStates: List, - loiNameDialogOpen: Boolean, + shouldShowLoiNameDialog: Boolean, shouldShowHeader: Boolean, showInstructionsDialog: Boolean, - initialNameValue: String, + loiName: String, onFooterPositionUpdated: (Float) -> Unit, - onButtonClicked: (ButtonAction) -> Unit, - onLoiNameConfirm: (String) -> Unit, - onLoiNameDismiss: () -> Unit, - onInstructionsDismiss: () -> Unit, + onAction: (TaskScreenAction) -> Unit, headerCard: @Composable (() -> Unit)?, taskBody: @Composable () -> Unit, - state: TaskScreenState = rememberTaskScreenState(initialLoiName = initialNameValue), ) { val isKeyboardOpen = WindowInsets.isImeVisible + var layoutCoordinates by remember { mutableStateOf(null) } // Update footer position whenever layout changes or keyboard is toggled. - LaunchedEffect(isKeyboardOpen, state.layoutCoordinates) { - state.layoutCoordinates?.let { onFooterPositionUpdated(it.positionInWindow().y) } + LaunchedEffect(isKeyboardOpen, layoutCoordinates) { + layoutCoordinates?.let { onFooterPositionUpdated(it.positionInWindow().y) } } TaskViewLayout( header = taskHeader, footer = { TaskFooter( - modifier = Modifier.onGloballyPositioned { state.layoutCoordinates = it }, + modifier = Modifier.onGloballyPositioned { layoutCoordinates = it }, headerCard = headerCard.takeIf { shouldShowHeader }, buttonActionStates = taskActionButtonsStates, - onButtonClicked = onButtonClicked, + onButtonClicked = { onAction(TaskScreenAction.OnButtonClicked(it)) }, ) }, content = { taskBody() }, ) - if (task.isAddLoiTask && loiNameDialogOpen) { + if (shouldShowLoiNameDialog) { LoiNameDialog( - textFieldValue = state.loiName, - onConfirmRequest = { onLoiNameConfirm(state.loiName) }, - onDismissRequest = { - state.loiName = initialNameValue - onLoiNameDismiss() - }, - onTextFieldChange = { state.loiName = it }, + textFieldValue = loiName, + onConfirmRequest = { onAction(TaskScreenAction.OnLoiNameConfirm(loiName)) }, + onDismissRequest = { onAction(TaskScreenAction.OnLoiNameDismiss) }, + onTextFieldChange = { onAction(TaskScreenAction.OnLoiNameChanged(it)) }, ) } instructionData ?.takeIf { showInstructionsDialog } - ?.let { InstructionsDialog(data = it, onDismissed = onInstructionsDismiss) } + ?.let { + InstructionsDialog( + data = it, + onDismissed = { onAction(TaskScreenAction.OnInstructionsDismiss) }, + ) + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt deleted file mode 100644 index d6d9f68427..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenState.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.layout.LayoutCoordinates - -/** State holder for the [TaskScreen] composable. */ -@Stable -class TaskScreenState(initialLoiName: String) { - var layoutCoordinates by mutableStateOf(null) - - var loiName by mutableStateOf(initialLoiName) -} - -@Composable -fun rememberTaskScreenState(initialLoiName: String): TaskScreenState = remember { - TaskScreenState(initialLoiName) -} From d249c5a5758593ac5f0ec74bfd63c1526fc1a81c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:20:36 +0530 Subject: [PATCH 12/20] Add basic test coverage --- .../ui/datacollection/tasks/TaskScreenTest.kt | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenTest.kt 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..7b0c588a0f --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenTest.kt @@ -0,0 +1,124 @@ +/* + * 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.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +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() + } +} From 6e8c479aef5ee68763d23ecc1eb563bf2d80267d Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:22:51 +0530 Subject: [PATCH 13/20] Improve test coverage --- .../ui/datacollection/tasks/TaskScreenTest.kt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 index 7b0c588a0f..05e2cedb82 100644 --- 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 @@ -18,11 +18,16 @@ 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.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 @@ -121,4 +126,59 @@ class TaskScreenTest { 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("My Custom LOI appended")) + } + + @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("Close").performClick() + assertThat(actionFired).isEqualTo(TaskScreenAction.OnInstructionsDismiss) + } } From 9ac0cab9928f81e4dcfc6b73c3676230c8d7b7ec Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:25:37 +0530 Subject: [PATCH 14/20] Fix tests --- .../android/ui/datacollection/tasks/TaskScreenTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 05e2cedb82..9bf11f586a 100644 --- 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 @@ -24,6 +24,7 @@ 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 @@ -154,8 +155,8 @@ class TaskScreenTest { composeTestRule.onNodeWithText("Cancel").performClick() assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameDismiss) - composeTestRule.onNodeWithTag(LOI_NAME_TEXT_FIELD_TEST_TAG).performTextInput(" appended") - assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameChanged("My Custom LOI appended")) + composeTestRule.onNodeWithTag(LOI_NAME_TEXT_FIELD_TEST_TAG).performTextInput("appended ") + assertThat(actionFired).isEqualTo(TaskScreenAction.OnLoiNameChanged("appended My Custom LOI")) } @Test @@ -178,7 +179,9 @@ class TaskScreenTest { ) } + composeTestRule.onNodeWithText(getString(R.string.add_point)).assertIsDisplayed() composeTestRule.onNodeWithText("Close").performClick() + assertThat(actionFired).isEqualTo(TaskScreenAction.OnInstructionsDismiss) } } From 3d8c64e07ac3f8b61239121c2d55bbe959a6eca4 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:27:52 +0530 Subject: [PATCH 15/20] Move `TaskScreenAction` to its own file --- .../ui/datacollection/tasks/TaskScreen.kt | 13 ------- .../datacollection/tasks/TaskScreenAction.kt | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskScreenAction.kt 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 index d3200ba1f9..58a44a2d45 100644 --- 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 @@ -28,7 +28,6 @@ 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.ButtonAction import org.groundplatform.android.ui.datacollection.components.ButtonActionState import org.groundplatform.android.ui.datacollection.components.InstructionData import org.groundplatform.android.ui.datacollection.components.InstructionsDialog @@ -37,18 +36,6 @@ import org.groundplatform.android.ui.datacollection.components.TaskFooter import org.groundplatform.android.ui.datacollection.components.TaskHeader import org.groundplatform.android.ui.datacollection.components.TaskViewLayout -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 -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun TaskScreen( 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..38295397ff --- /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 +} \ No newline at end of file From 79386da7ccac7c484935111029aadab9e7fcd2ce Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:29:35 +0530 Subject: [PATCH 16/20] Fix detekt issue --- .../android/ui/datacollection/tasks/TaskScreenAction.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 38295397ff..d4f9afa109 100644 --- 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 @@ -31,4 +31,4 @@ sealed interface TaskScreenAction { data class OnLoiNameChanged(val name: String) : TaskScreenAction data object OnInstructionsDismiss : TaskScreenAction -} \ No newline at end of file +} From d5a769ff3c17f14f9ef34c29b80068e567dce954 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:44:12 +0530 Subject: [PATCH 17/20] Fix IllegalStateException that was causing crash The issue occurred because Google Maps loads asynchronously, and its listeners can fire even when the Fragment's view is being destroyed. If the user navigated away from the fragment before getMapAsync finished, onMapReady() would attempt to access viewLifecycleOwner when the fragment's view was already null (after onDestroyView()). --- .../groundplatform/android/ui/map/gms/GoogleMapsFragment.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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) } } } From 39350ddbe7095add96a575b7c496cabb9bfd612c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 15:47:01 +0530 Subject: [PATCH 18/20] Update detekt baseline --- config/detekt/baseline.xml | 1 + 1 file changed, 1 insertion(+) 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? From 5d9fff3a7f45dc8158e28bd3e57bfc6bbfe7e59c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 16:00:03 +0530 Subject: [PATCH 19/20] Add coverage for onFooterPositionUpdated --- .../ui/datacollection/tasks/TaskScreenTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 index 9bf11f586a..9225df7ca4 100644 --- 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 @@ -184,4 +184,32 @@ class TaskScreenTest { 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) + } } From 90989baac0df03d9ad9d4733f00925bbaff01dbf Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 30 Mar 2026 16:03:48 +0530 Subject: [PATCH 20/20] Improve test coverage using negative states --- .../ui/datacollection/tasks/TaskScreenTest.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) 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 index 9225df7ca4..096fb01948 100644 --- 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 @@ -212,4 +212,88 @@ class TaskScreenTest { // 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() + } }