From cac0bbcdc6ac321bba0a97e5c8bf58d01bd9dfc2 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 9 Sep 2026 11:05:25 +0530 Subject: [PATCH 1/4] Migrate AbstractTaskMapFragment to Jetpack Compose - Replace map_task_frag.xml with pure Compose TaskMapScreen - Update AbstractTaskMapFragment to inflate BasemapLayoutBinding - Drive center crosshair and location info via StateFlows - Remove obsolete map_task_frag.xml layout - Add unit tests for TaskMapScreen and remove dead methods from TaskFragmentRunner --- .../tasks/AbstractTaskMapFragment.kt | 112 ++++------ .../ui/datacollection/tasks/TaskMapScreen.kt | 207 ++++++++++++++++++ app/src/main/res/layout/map_task_frag.xml | 149 ------------- .../ui/datacollection/TaskFragmentRunner.kt | 18 -- .../datacollection/tasks/TaskMapScreenTest.kt | 190 ++++++++++++++++ 5 files changed, 436 insertions(+), 240 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt delete mode 100644 app/src/main/res/layout/map_task_frag.xml create mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt index c9e670e907..cd9c0465f2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt @@ -20,11 +20,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -34,15 +31,13 @@ import androidx.lifecycle.withStarted import java.math.RoundingMode import java.text.DecimalFormat import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.groundplatform.android.R -import org.groundplatform.android.databinding.MapTaskFragBinding +import org.groundplatform.android.databinding.BasemapLayoutBinding import org.groundplatform.android.ui.common.AbstractMapContainerFragment import org.groundplatform.android.ui.common.BaseMapViewModel -import org.groundplatform.android.ui.components.MapFloatingActionButton -import org.groundplatform.android.ui.components.MapFloatingActionButtonType -import org.groundplatform.android.ui.components.RecenterButton import org.groundplatform.android.ui.datacollection.DataCollectionFragment import org.groundplatform.android.ui.datacollection.DataCollectionViewModel import org.groundplatform.android.ui.map.Feature @@ -58,8 +53,6 @@ import org.jetbrains.annotations.MustBeInvokedByOverriders abstract class AbstractTaskMapFragment : AbstractMapContainerFragment() { - protected lateinit var binding: MapTaskFragBinding - protected val dataCollectionViewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection) @@ -72,6 +65,9 @@ abstract class AbstractTaskMapFragment : private lateinit var viewModel: BaseMapViewModel + private val _isCenterMarkerVisible = MutableStateFlow(true) + private val _locationInfo = MutableStateFlow(null) + protected val taskId: String by lazy { arguments?.getString(DataCollectionFragment.TASK_ID) ?: error("null taskId fragment arg") } @@ -88,8 +84,25 @@ abstract class AbstractTaskMapFragment : ): View { super.onCreateView(inflater, container, savedInstanceState) - binding = MapTaskFragBinding.inflate(inflater, container, false) - setupMapActionButtons() + val binding = BasemapLayoutBinding.inflate(inflater, container, false) + binding.composeContent.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setComposableContent { + val locationLockButton by viewModel.locationLockIconType.collectAsStateWithLifecycle() + val shouldShowRecenter by viewModel.shouldShowRecenterButton.collectAsStateWithLifecycle() + val isCenterMarkerVisible by _isCenterMarkerVisible.collectAsStateWithLifecycle() + val locationInfo by _locationInfo.collectAsStateWithLifecycle() + + TaskMapScreen( + locationLockButtonType = locationLockButton, + shouldShowRecenter = shouldShowRecenter, + isCenterMarkerVisible = isCenterMarkerVisible, + locationInfo = locationInfo, + onMapTypeClicked = { showMapTypeSelectorDialog() }, + onLocationLockClicked = { viewModel.onLocationLockClick() }, + ) + } + } viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -109,43 +122,6 @@ abstract class AbstractTaskMapFragment : return binding.root } - private fun setupMapActionButtons() { - binding.mapTypeBtn.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - MapFloatingActionButton( - type = MapFloatingActionButtonType.MapType, - onClick = { showMapTypeSelectorDialog() }, - ) - } - } - - binding.locationLockBtn.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - val locationLockButton by viewModel.locationLockIconType.collectAsStateWithLifecycle() - - MapFloatingActionButton( - type = locationLockButton, - onClick = { viewModel.onLocationLockClick() }, - ) - } - } - - binding.recenterBtn.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - val shouldShowRecenter by viewModel.shouldShowRecenterButton.collectAsStateWithLifecycle() - - if (shouldShowRecenter) - RecenterButton( - modifier = Modifier.padding(start = 20.dp), - onClick = { viewModel.onLocationLockClick() }, - ) - } - } - } - override fun getMapViewModel(): BaseMapViewModel = viewModel @MustBeInvokedByOverriders @@ -171,34 +147,24 @@ abstract class AbstractTaskMapFragment : locationText: String?, accuracyText: String? = null, accuracyInMeters: Double? = null, - ) = - with(binding) { - if (locationText.isNullOrEmpty()) { - infoCard.visibility = View.GONE - } else { - infoCard.visibility = View.VISIBLE - currentLocationTitle.text = getString(title) - currentLocationValue.text = locationText - } - - if (accuracyText.isNullOrEmpty()) { - accuracy.visibility = View.GONE - } else { - accuracy.visibility = View.VISIBLE - accuracyTitle.setText(R.string.accuracy) - accuracyValue.text = accuracyText - val color = - if (accuracyInMeters == null || accuracyInMeters > ACCURACY_THRESHOLD_IN_M) { - R.color.accuracy_bad - } else { - R.color.accuracy_good - } - accuracyValue.setTextColor(resources.getColor(color, null)) - } + ) { + if (locationText.isNullOrEmpty()) { + _locationInfo.value = null + return } + val isGood = accuracyInMeters != null && accuracyInMeters <= ACCURACY_THRESHOLD_IN_M + _locationInfo.value = + LocationInfo( + titleRes = title, + locationText = locationText, + accuracyText = accuracyText, + isAccuracyGood = isGood, + ) + } + fun setCenterMarkerVisibility(visible: Boolean) { - binding.centerMarker.visibility = if (visible) View.VISIBLE else View.GONE + _isCenterMarkerVisible.value = visible } @MustBeInvokedByOverriders diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt new file mode 100644 index 0000000000..be6b1f6570 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt @@ -0,0 +1,207 @@ +/* + * 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.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.MapFloatingActionButton +import org.groundplatform.android.ui.components.MapFloatingActionButtonType +import org.groundplatform.android.ui.components.RecenterButton +import org.groundplatform.ui.theme.AppTheme + +@VisibleForTesting +object TaskMapScreenTestTags { + const val CENTER_MARKER = "task_map_center_marker" + const val LOCATION_INFO_CARD = "task_map_location_info_card" + const val CURRENT_LOCATION_TITLE = "task_map_current_location_title" + const val CURRENT_LOCATION_VALUE = "task_map_current_location_value" + const val ACCURACY_TITLE = "task_map_accuracy_title" + const val ACCURACY_VALUE = "task_map_accuracy_value" +} + +@Immutable +data class LocationInfo( + @StringRes val titleRes: Int, + val locationText: String, + val accuracyText: String? = null, + val isAccuracyGood: Boolean = false, +) + +/** + * Screen overlay for map-based tasks, including center crosshair, map type button, recenter button, + * location lock button, and location info card. + */ +@Composable +fun TaskMapScreen( + modifier: Modifier = Modifier, + locationLockButtonType: MapFloatingActionButtonType, + shouldShowRecenter: Boolean, + isCenterMarkerVisible: Boolean, + locationInfo: LocationInfo?, + onMapTypeClicked: () -> Unit, + onLocationLockClicked: () -> Unit, +) { + Box(modifier = modifier.fillMaxSize()) { + if (isCenterMarkerVisible) { + Image( + painter = painterResource(R.drawable.ic_plus_sign), + contentDescription = null, + modifier = + Modifier.align(Alignment.Center).scale(0.5f).testTag(TaskMapScreenTestTags.CENTER_MARKER), + ) + } + + MapFloatingActionButton( + modifier = Modifier.align(Alignment.TopEnd), + type = MapFloatingActionButtonType.MapType, + onClick = onMapTypeClicked, + ) + + Column(modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()) { + if (shouldShowRecenter) { + RecenterButton( + modifier = Modifier.padding(start = 20.dp, bottom = 8.dp), + onClick = onLocationLockClicked, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, bottom = 4.dp), + verticalAlignment = Alignment.Bottom, + ) { + if (locationInfo != null) { + LocationInfoCard( + locationInfo = locationInfo, + modifier = Modifier.weight(1f).padding(bottom = 16.dp, end = 8.dp), + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + MapFloatingActionButton(type = locationLockButtonType, onClick = onLocationLockClicked) + } + } + } +} + +@Composable +fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) { + Card( + modifier = modifier.testTag(TaskMapScreenTestTags.LOCATION_INFO_CARD), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = + if (locationInfo.accuracyText != null) { + Modifier.weight(0.75f) + } else { + Modifier.fillMaxWidth() + } + ) { + Text( + text = stringResource(locationInfo.titleRes), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF5E5E5E), + modifier = Modifier.testTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE), + ) + Text( + text = locationInfo.locationText, + style = MaterialTheme.typography.labelMedium, + color = Color(0xFF424940), + modifier = Modifier.testTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE), + ) + } + + if (locationInfo.accuracyText != null) { + Column(modifier = Modifier.weight(0.25f)) { + Text( + text = stringResource(R.string.accuracy), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF5E5E5E), + modifier = Modifier.testTag(TaskMapScreenTestTags.ACCURACY_TITLE), + ) + Text( + text = locationInfo.accuracyText, + style = MaterialTheme.typography.labelMedium, + color = + colorResource( + if (locationInfo.isAccuracyGood) { + R.color.accuracy_good + } else { + R.color.accuracy_bad + } + ), + modifier = Modifier.testTag(TaskMapScreenTestTags.ACCURACY_VALUE), + ) + } + } + } + } +} + +@Preview +@Composable +@ExcludeFromJacocoGeneratedReport +private fun TaskMapScreenPreview() { + AppTheme { + TaskMapScreen( + locationLockButtonType = MapFloatingActionButtonType.LocationNotLocked, + shouldShowRecenter = true, + isCenterMarkerVisible = true, + locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "3m", + isAccuracyGood = true, + ), + onMapTypeClicked = {}, + onLocationLockClicked = {}, + ) + } +} diff --git a/app/src/main/res/layout/map_task_frag.xml b/app/src/main/res/layout/map_task_frag.xml deleted file mode 100644 index 49a3ea080c..0000000000 --- a/app/src/main/res/layout/map_task_frag.xml +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/TaskFragmentRunner.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/TaskFragmentRunner.kt index 26aeef97fd..ca56360cfd 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/TaskFragmentRunner.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/TaskFragmentRunner.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.test.performTextInput import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -53,7 +52,6 @@ import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.OTHER_I import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.SELECT_MULTIPLE_RADIO_TEST_TAG import org.groundplatform.android.ui.datacollection.tasks.number.INPUT_NUMBER_TEST_TAG import org.groundplatform.android.ui.datacollection.tasks.text.INPUT_TEXT_TEST_TAG -import org.hamcrest.CoreMatchers.not /** Helper class for interacting with the data collection tasks and verifying the ui state. */ class TaskFragmentRunner( @@ -171,22 +169,6 @@ class TaskFragmentRunner( return this } - internal fun assertInfoCardHidden(): TaskFragmentRunner { - onView(withId(R.id.infoCard)).check(matches(not(isDisplayed()))) - return this - } - - internal fun assertInfoCardShown( - title: String, - location: String, - accuracy: String, - ): TaskFragmentRunner { - onView(withId(R.id.current_location_title)).check(matches(withText(title))) - onView(withId(R.id.current_location_value)).check(matches(withText(location))) - onView(withId(R.id.accuracy_value)).check(matches(withText(accuracy))) - return this - } - internal fun pressBackButton(): TaskFragmentRunner { waitUntilDone { assertThat(fragment?.onBack()).isTrue() } return this diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt new file mode 100644 index 0000000000..29524f5c5c --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt @@ -0,0 +1,190 @@ +/* + * 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.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.ui.components.LOCATION_LOCKED_TEST_TAG +import org.groundplatform.android.ui.components.LOCATION_NOT_LOCKED_TEST_TAG +import org.groundplatform.android.ui.components.MapFloatingActionButtonType +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TaskMapScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + private fun setContent( + locationLockButtonType: MapFloatingActionButtonType = + MapFloatingActionButtonType.LocationNotLocked, + shouldShowRecenter: Boolean = false, + isCenterMarkerVisible: Boolean = true, + locationInfo: LocationInfo? = null, + onMapTypeClicked: () -> Unit = {}, + onLocationLockClicked: () -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + TaskMapScreen( + locationLockButtonType = locationLockButtonType, + shouldShowRecenter = shouldShowRecenter, + isCenterMarkerVisible = isCenterMarkerVisible, + locationInfo = locationInfo, + onMapTypeClicked = onMapTypeClicked, + onLocationLockClicked = onLocationLockClicked, + ) + } + } + } + + @Test + fun `Center marker is displayed when isCenterMarkerVisible is true`() { + setContent(isCenterMarkerVisible = true) + + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.CENTER_MARKER).assertIsDisplayed() + } + + @Test + fun `Center marker is not displayed when isCenterMarkerVisible is false`() { + setContent(isCenterMarkerVisible = false) + + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.CENTER_MARKER).assertDoesNotExist() + } + + @Test + fun `Map type button is displayed and clicking triggers callback`() { + var mapTypeClicked = false + setContent(onMapTypeClicked = { mapTypeClicked = true }) + + composeTestRule + .onNodeWithTag(MapFloatingActionButtonType.MapType.testTag) + .assertIsDisplayed() + .performClick() + + assertThat(mapTypeClicked).isTrue() + } + + @Test + fun `Recenter button is displayed when shouldShowRecenter is true and clicking triggers callback`() { + var recenterClicked = false + setContent( + shouldShowRecenter = true, + onLocationLockClicked = { recenterClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.recenter)).assertIsDisplayed().performClick() + + assertThat(recenterClicked).isTrue() + } + + @Test + fun `Recenter button is not displayed when shouldShowRecenter is false`() { + setContent(shouldShowRecenter = false) + + composeTestRule.onNodeWithText(getString(R.string.recenter)).assertDoesNotExist() + } + + @Test + fun `Location lock button is displayed with correct icon when not locked and clicking triggers callback`() { + var locationLockClicked = false + setContent( + locationLockButtonType = MapFloatingActionButtonType.LocationNotLocked, + onLocationLockClicked = { locationLockClicked = true }, + ) + + composeTestRule.onNodeWithTag(LOCATION_NOT_LOCKED_TEST_TAG).assertIsDisplayed().performClick() + + assertThat(locationLockClicked).isTrue() + } + + @Test + fun `Location lock button is displayed with correct icon when locked`() { + setContent(locationLockButtonType = MapFloatingActionButtonType.LocationLocked()) + + composeTestRule.onNodeWithTag(LOCATION_LOCKED_TEST_TAG).assertIsDisplayed() + } + + @Test + fun `Location info card is not displayed when locationInfo is null`() { + setContent(locationInfo = null) + + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertDoesNotExist() + } + + @Test + fun `Location info card is displayed with current location and accuracy when accuracy is provided`() { + val locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "3m", + isAccuracyGood = true, + ) + setContent(locationInfo = locationInfo) + + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.current_location)) + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.ACCURACY_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.accuracy)) + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.ACCURACY_VALUE) + .assertIsDisplayed() + .assertTextEquals("3m") + } + + @Test + fun `Location info card is displayed with map location and no accuracy when accuracy is null`() { + val locationInfo = + LocationInfo( + titleRes = R.string.map_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = null, + ) + setContent(locationInfo = locationInfo) + + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.map_location)) + composeTestRule + .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.ACCURACY_TITLE).assertDoesNotExist() + composeTestRule.onNodeWithTag(TaskMapScreenTestTags.ACCURACY_VALUE).assertDoesNotExist() + } +} From c156bb69c2551ea16f250b2015f2a7251a43583c Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 9 Sep 2026 11:16:53 +0530 Subject: [PATCH 2/4] Refactor TaskMapScreen into tasks/map and extract LocationInfoCard component - Move TaskMapScreen into dedicated ui.datacollection.tasks.map package - Extract LocationInfo and LocationInfoCard leaf component into tasks.map.components package - Update AbstractTaskMapFragment with new package imports - Mirror test structure with TaskMapScreenTest and LocationInfoCardTest in matching packages --- .../tasks/AbstractTaskMapFragment.kt | 2 + .../tasks/{ => map}/TaskMapScreen.kt | 91 +----------- .../tasks/map/components/LocationInfoCard.kt | 136 ++++++++++++++++++ .../tasks/{ => map}/TaskMapScreenTest.kt | 50 +------ .../map/components/LocationInfoCardTest.kt | 87 +++++++++++ 5 files changed, 236 insertions(+), 130 deletions(-) rename app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/{ => map}/TaskMapScreen.kt (57%) create mode 100644 app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt rename app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/{ => map}/TaskMapScreenTest.kt (70%) create mode 100644 app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt index cd9c0465f2..93aa854041 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt @@ -40,6 +40,8 @@ import org.groundplatform.android.ui.common.AbstractMapContainerFragment import org.groundplatform.android.ui.common.BaseMapViewModel import org.groundplatform.android.ui.datacollection.DataCollectionFragment import org.groundplatform.android.ui.datacollection.DataCollectionViewModel +import org.groundplatform.android.ui.datacollection.tasks.map.TaskMapScreen +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfo import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.MapFragment import org.groundplatform.android.ui.map.gms.getAccuracyOrNull diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt similarity index 57% rename from app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt rename to app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt index be6b1f6570..36c7e22e93 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.ui.datacollection.tasks +package org.groundplatform.android.ui.datacollection.tasks.map -import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -25,21 +24,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.groundplatform.android.R @@ -47,29 +37,18 @@ import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.android.ui.components.MapFloatingActionButton import org.groundplatform.android.ui.components.MapFloatingActionButtonType import org.groundplatform.android.ui.components.RecenterButton +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfo +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfoCard import org.groundplatform.ui.theme.AppTheme @VisibleForTesting object TaskMapScreenTestTags { const val CENTER_MARKER = "task_map_center_marker" - const val LOCATION_INFO_CARD = "task_map_location_info_card" - const val CURRENT_LOCATION_TITLE = "task_map_current_location_title" - const val CURRENT_LOCATION_VALUE = "task_map_current_location_value" - const val ACCURACY_TITLE = "task_map_accuracy_title" - const val ACCURACY_VALUE = "task_map_accuracy_value" } -@Immutable -data class LocationInfo( - @StringRes val titleRes: Int, - val locationText: String, - val accuracyText: String? = null, - val isAccuracyGood: Boolean = false, -) - /** - * Screen overlay for map-based tasks, including center crosshair, map type button, recenter button, - * location lock button, and location info card. + * Screen overlay for map-based tasks, including center crosshairs, map type button, recenter + * button, location lock button, and location info card. */ @Composable fun TaskMapScreen( @@ -124,66 +103,6 @@ fun TaskMapScreen( } } -@Composable -fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) { - Card( - modifier = modifier.testTag(TaskMapScreenTestTags.LOCATION_INFO_CARD), - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = - if (locationInfo.accuracyText != null) { - Modifier.weight(0.75f) - } else { - Modifier.fillMaxWidth() - } - ) { - Text( - text = stringResource(locationInfo.titleRes), - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF5E5E5E), - modifier = Modifier.testTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE), - ) - Text( - text = locationInfo.locationText, - style = MaterialTheme.typography.labelMedium, - color = Color(0xFF424940), - modifier = Modifier.testTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE), - ) - } - - if (locationInfo.accuracyText != null) { - Column(modifier = Modifier.weight(0.25f)) { - Text( - text = stringResource(R.string.accuracy), - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF5E5E5E), - modifier = Modifier.testTag(TaskMapScreenTestTags.ACCURACY_TITLE), - ) - Text( - text = locationInfo.accuracyText, - style = MaterialTheme.typography.labelMedium, - color = - colorResource( - if (locationInfo.isAccuracyGood) { - R.color.accuracy_good - } else { - R.color.accuracy_bad - } - ), - modifier = Modifier.testTag(TaskMapScreenTestTags.ACCURACY_VALUE), - ) - } - } - } - } -} - @Preview @Composable @ExcludeFromJacocoGeneratedReport diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt new file mode 100644 index 0000000000..d78254f2a3 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt @@ -0,0 +1,136 @@ +/* + * 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.map.components + +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.ui.theme.AppTheme + +@VisibleForTesting +object LocationInfoCardTestTags { + const val LOCATION_INFO_CARD = "location_info_card" + const val CURRENT_LOCATION_TITLE = "location_info_card_title" + const val CURRENT_LOCATION_VALUE = "location_info_card_value" + const val ACCURACY_TITLE = "location_info_card_accuracy_title" + const val ACCURACY_VALUE = "location_info_card_accuracy_value" +} + +@Immutable +data class LocationInfo( + @StringRes val titleRes: Int, + val locationText: String, + val accuracyText: String? = null, + val isAccuracyGood: Boolean = false, +) + +/** A floating card displaying current/map coordinates and GPS accuracy. */ +@Composable +fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) { + Card( + modifier = modifier.testTag(LocationInfoCardTestTags.LOCATION_INFO_CARD), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = + if (locationInfo.accuracyText != null) { + Modifier.weight(0.75f) + } else { + Modifier.fillMaxWidth() + } + ) { + Text( + text = stringResource(locationInfo.titleRes), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF5E5E5E), + modifier = Modifier.testTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE), + ) + Text( + text = locationInfo.locationText, + style = MaterialTheme.typography.labelMedium, + color = Color(0xFF424940), + modifier = Modifier.testTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE), + ) + } + + if (locationInfo.accuracyText != null) { + Column(modifier = Modifier.weight(0.25f)) { + Text( + text = stringResource(R.string.accuracy), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF5E5E5E), + modifier = Modifier.testTag(LocationInfoCardTestTags.ACCURACY_TITLE), + ) + Text( + text = locationInfo.accuracyText, + style = MaterialTheme.typography.labelMedium, + color = + colorResource( + if (locationInfo.isAccuracyGood) { + R.color.accuracy_good + } else { + R.color.accuracy_bad + } + ), + modifier = Modifier.testTag(LocationInfoCardTestTags.ACCURACY_VALUE), + ) + } + } + } + } +} + +@Preview +@Composable +@ExcludeFromJacocoGeneratedReport +private fun LocationInfoCardPreview() { + AppTheme { + LocationInfoCard( + locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "3m", + isAccuracyGood = true, + ) + ) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt similarity index 70% rename from app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt rename to app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt index 29524f5c5c..112952b228 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/TaskMapScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt @@ -13,10 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.ui.datacollection.tasks +package org.groundplatform.android.ui.datacollection.tasks.map import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -27,6 +26,8 @@ import org.groundplatform.android.getString import org.groundplatform.android.ui.components.LOCATION_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.LOCATION_NOT_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.MapFloatingActionButtonType +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfo +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfoCardTestTags import org.groundplatform.ui.theme.AppTheme import org.junit.Rule import org.junit.Test @@ -132,11 +133,11 @@ class TaskMapScreenTest { fun `Location info card is not displayed when locationInfo is null`() { setContent(locationInfo = null) - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertDoesNotExist() + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertDoesNotExist() } @Test - fun `Location info card is displayed with current location and accuracy when accuracy is provided`() { + fun `Location info card is displayed when locationInfo is not null`() { val locationInfo = LocationInfo( titleRes = R.string.current_location, @@ -146,45 +147,6 @@ class TaskMapScreenTest { ) setContent(locationInfo = locationInfo) - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertIsDisplayed() - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE) - .assertIsDisplayed() - .assertTextEquals(getString(R.string.current_location)) - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE) - .assertIsDisplayed() - .assertTextEquals("29º58’15” N 114º36’17”W") - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.ACCURACY_TITLE) - .assertIsDisplayed() - .assertTextEquals(getString(R.string.accuracy)) - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.ACCURACY_VALUE) - .assertIsDisplayed() - .assertTextEquals("3m") - } - - @Test - fun `Location info card is displayed with map location and no accuracy when accuracy is null`() { - val locationInfo = - LocationInfo( - titleRes = R.string.map_location, - locationText = "29º58’15” N 114º36’17”W", - accuracyText = null, - ) - setContent(locationInfo = locationInfo) - - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.LOCATION_INFO_CARD).assertIsDisplayed() - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_TITLE) - .assertIsDisplayed() - .assertTextEquals(getString(R.string.map_location)) - composeTestRule - .onNodeWithTag(TaskMapScreenTestTags.CURRENT_LOCATION_VALUE) - .assertIsDisplayed() - .assertTextEquals("29º58’15” N 114º36’17”W") - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.ACCURACY_TITLE).assertDoesNotExist() - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.ACCURACY_VALUE).assertDoesNotExist() + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt new file mode 100644 index 0000000000..2ff42e2af7 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt @@ -0,0 +1,87 @@ +/* + * 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.map.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LocationInfoCardTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `Location info card is displayed with current location and accuracy when accuracy is provided`() { + val locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "3m", + isAccuracyGood = true, + ) + composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } + + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.current_location)) + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.ACCURACY_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.accuracy)) + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.ACCURACY_VALUE) + .assertIsDisplayed() + .assertTextEquals("3m") + } + + @Test + fun `Location info card is displayed with map location and no accuracy when accuracy is null`() { + val locationInfo = + LocationInfo( + titleRes = R.string.map_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = null, + ) + composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } + + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.map_location)) + composeTestRule + .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.ACCURACY_TITLE).assertDoesNotExist() + composeTestRule.onNodeWithTag(LocationInfoCardTestTags.ACCURACY_VALUE).assertDoesNotExist() + } +} From 471b8a8a97178278f30f7dfee97d038ca1b1ebdb Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 9 Sep 2026 11:43:37 +0530 Subject: [PATCH 3/4] Address multi-agent code review findings for TaskMapScreen - Fix race condition during map pan by guarding location.collect with locationLock check - Fix Fragment coroutine lifecycle scoping with viewLifecycleOwner.repeatOnLifecycle in onViewCreated - Reset location info and crosshairs state in onDestroyView - Hoist DecimalFormat to companion object in AbstractTaskMapFragment - Adopt Material 3 colorScheme tokens, weight(1f), and blank checks in LocationInfoCard - Reorder modifier parameter and convert test tags to top-level const val in TaskMapScreen - Add missing branch and edge-case unit tests in LocationInfoCardTest and TaskMapScreenTest --- .../tasks/AbstractTaskMapFragment.kt | 48 ++++++++---- .../datacollection/tasks/map/TaskMapScreen.kt | 9 +-- .../tasks/map/components/LocationInfoCard.kt | 42 +++++----- .../tasks/map/TaskMapScreenTest.kt | 45 +++++++++-- .../map/components/LocationInfoCardTest.kt | 77 ++++++++++++++++--- 5 files changed, 161 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt index 93aa854041..ffd1fa4fa6 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt @@ -65,15 +65,15 @@ abstract class AbstractTaskMapFragment : dataCollectionViewModel.getTaskViewModel(taskId) as TVM } + protected val taskId: String by lazy { + arguments?.getString(DataCollectionFragment.TASK_ID) ?: error("null taskId fragment arg") + } + private lateinit var viewModel: BaseMapViewModel private val _isCenterMarkerVisible = MutableStateFlow(true) private val _locationInfo = MutableStateFlow(null) - protected val taskId: String by lazy { - arguments?.getString(DataCollectionFragment.TASK_ID) ?: error("null taskId fragment arg") - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel = getViewModel(BaseMapViewModel::class.java) @@ -106,22 +106,37 @@ abstract class AbstractTaskMapFragment : } } - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - getMapViewModel().location.collect { - val locationText = it?.toCoordinates()?.toDmsFormat() + return binding.root + } - val df = DecimalFormat("#.##") - df.roundingMode = RoundingMode.DOWN - val accuracy = it?.getAccuracyOrNull() - val accuracyText = accuracy?.let { value -> df.format(value) + "m" } ?: "?" + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - updateLocationInfoCard(R.string.current_location, locationText, accuracyText, accuracy) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + getMapViewModel().location.collect { + if (getMapViewModel().locationLock.value.getOrDefault(false)) { + val locationText = it?.toCoordinates()?.toDmsFormat() + val accuracy = it?.getAccuracyOrNull() + val accuracyText = + accuracy?.let { value -> ACCURACY_FORMATTER.format(value) + "m" } ?: "?" + + updateLocationInfoCard( + R.string.current_location, + locationText, + accuracyText, + accuracy, + ) + } } } } + } - return binding.root + override fun onDestroyView() { + super.onDestroyView() + _locationInfo.value = null + _isCenterMarkerVisible.value = true } override fun getMapViewModel(): BaseMapViewModel = viewModel @@ -177,4 +192,9 @@ abstract class AbstractTaskMapFragment : } updateLocationInfoCard(R.string.map_location, position.coordinates.toDmsFormat()) } + + companion object { + private val ACCURACY_FORMATTER = + DecimalFormat("#.##").apply { roundingMode = RoundingMode.DOWN } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt index 36c7e22e93..639ce8c1fd 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt @@ -41,10 +41,7 @@ import org.groundplatform.android.ui.datacollection.tasks.map.components.Locatio import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfoCard import org.groundplatform.ui.theme.AppTheme -@VisibleForTesting -object TaskMapScreenTestTags { - const val CENTER_MARKER = "task_map_center_marker" -} +@VisibleForTesting const val TASK_MAP_CENTER_MARKER_TEST_TAG = "task_map_center_marker" /** * Screen overlay for map-based tasks, including center crosshairs, map type button, recenter @@ -52,13 +49,13 @@ object TaskMapScreenTestTags { */ @Composable fun TaskMapScreen( - modifier: Modifier = Modifier, locationLockButtonType: MapFloatingActionButtonType, shouldShowRecenter: Boolean, isCenterMarkerVisible: Boolean, locationInfo: LocationInfo?, onMapTypeClicked: () -> Unit, onLocationLockClicked: () -> Unit, + modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { if (isCenterMarkerVisible) { @@ -66,7 +63,7 @@ fun TaskMapScreen( painter = painterResource(R.drawable.ic_plus_sign), contentDescription = null, modifier = - Modifier.align(Alignment.Center).scale(0.5f).testTag(TaskMapScreenTestTags.CENTER_MARKER), + Modifier.align(Alignment.Center).scale(0.5f).testTag(TASK_MAP_CENTER_MARKER_TEST_TAG), ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt index d78254f2a3..2ce7314462 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource @@ -40,14 +39,11 @@ import org.groundplatform.android.R import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.ui.theme.AppTheme -@VisibleForTesting -object LocationInfoCardTestTags { - const val LOCATION_INFO_CARD = "location_info_card" - const val CURRENT_LOCATION_TITLE = "location_info_card_title" - const val CURRENT_LOCATION_VALUE = "location_info_card_value" - const val ACCURACY_TITLE = "location_info_card_accuracy_title" - const val ACCURACY_VALUE = "location_info_card_accuracy_value" -} +@VisibleForTesting const val LOCATION_INFO_CARD_TEST_TAG = "location_info_card" +@VisibleForTesting const val CURRENT_LOCATION_TITLE_TEST_TAG = "location_info_card_title" +@VisibleForTesting const val CURRENT_LOCATION_VALUE_TEST_TAG = "location_info_card_value" +@VisibleForTesting const val ACCURACY_TITLE_TEST_TAG = "location_info_card_accuracy_title" +@VisibleForTesting const val ACCURACY_VALUE_TEST_TAG = "location_info_card_accuracy_value" @Immutable data class LocationInfo( @@ -60,10 +56,12 @@ data class LocationInfo( /** A floating card displaying current/map coordinates and GPS accuracy. */ @Composable fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) { + val hasAccuracy = !locationInfo.accuracyText.isNullOrBlank() + Card( - modifier = modifier.testTag(LocationInfoCardTestTags.LOCATION_INFO_CARD), + modifier = modifier.testTag(LOCATION_INFO_CARD_TEST_TAG), shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Row( modifier = Modifier.fillMaxWidth().padding(8.dp), @@ -71,36 +69,36 @@ fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) ) { Column( modifier = - if (locationInfo.accuracyText != null) { + if (hasAccuracy) { Modifier.weight(0.75f) } else { - Modifier.fillMaxWidth() + Modifier.weight(1f) } ) { Text( text = stringResource(locationInfo.titleRes), style = MaterialTheme.typography.labelSmall, - color = Color(0xFF5E5E5E), - modifier = Modifier.testTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(CURRENT_LOCATION_TITLE_TEST_TAG), ) Text( text = locationInfo.locationText, style = MaterialTheme.typography.labelMedium, - color = Color(0xFF424940), - modifier = Modifier.testTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag(CURRENT_LOCATION_VALUE_TEST_TAG), ) } - if (locationInfo.accuracyText != null) { + if (hasAccuracy) { Column(modifier = Modifier.weight(0.25f)) { Text( text = stringResource(R.string.accuracy), style = MaterialTheme.typography.labelSmall, - color = Color(0xFF5E5E5E), - modifier = Modifier.testTag(LocationInfoCardTestTags.ACCURACY_TITLE), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ACCURACY_TITLE_TEST_TAG), ) Text( - text = locationInfo.accuracyText, + text = locationInfo.accuracyText.orEmpty(), style = MaterialTheme.typography.labelMedium, color = colorResource( @@ -110,7 +108,7 @@ fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) R.color.accuracy_bad } ), - modifier = Modifier.testTag(LocationInfoCardTestTags.ACCURACY_VALUE), + modifier = Modifier.testTag(ACCURACY_VALUE_TEST_TAG), ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt index 112952b228..1aa235d3e0 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt @@ -26,8 +26,8 @@ import org.groundplatform.android.getString import org.groundplatform.android.ui.components.LOCATION_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.LOCATION_NOT_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.MapFloatingActionButtonType +import org.groundplatform.android.ui.datacollection.tasks.map.components.LOCATION_INFO_CARD_TEST_TAG import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfo -import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfoCardTestTags import org.groundplatform.ui.theme.AppTheme import org.junit.Rule import org.junit.Test @@ -66,14 +66,14 @@ class TaskMapScreenTest { fun `Center marker is displayed when isCenterMarkerVisible is true`() { setContent(isCenterMarkerVisible = true) - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.CENTER_MARKER).assertIsDisplayed() + composeTestRule.onNodeWithTag(TASK_MAP_CENTER_MARKER_TEST_TAG).assertIsDisplayed() } @Test fun `Center marker is not displayed when isCenterMarkerVisible is false`() { setContent(isCenterMarkerVisible = false) - composeTestRule.onNodeWithTag(TaskMapScreenTestTags.CENTER_MARKER).assertDoesNotExist() + composeTestRule.onNodeWithTag(TASK_MAP_CENTER_MARKER_TEST_TAG).assertDoesNotExist() } @Test @@ -123,17 +123,23 @@ class TaskMapScreenTest { } @Test - fun `Location lock button is displayed with correct icon when locked`() { - setContent(locationLockButtonType = MapFloatingActionButtonType.LocationLocked()) + fun `Location lock button is displayed with correct icon when locked and clicking triggers callback`() { + var locationLockClicked = false + setContent( + locationLockButtonType = MapFloatingActionButtonType.LocationLocked(), + onLocationLockClicked = { locationLockClicked = true }, + ) - composeTestRule.onNodeWithTag(LOCATION_LOCKED_TEST_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_LOCKED_TEST_TAG).assertIsDisplayed().performClick() + + assertThat(locationLockClicked).isTrue() } @Test fun `Location info card is not displayed when locationInfo is null`() { setContent(locationInfo = null) - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertDoesNotExist() + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertDoesNotExist() } @Test @@ -147,6 +153,29 @@ class TaskMapScreenTest { ) setContent(locationInfo = locationInfo) - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() + } + + @Test + fun `Recenter button and info card are both displayed when requested`() { + val locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "3m", + isAccuracyGood = true, + ) + var locationLockClicked = false + setContent( + shouldShowRecenter = true, + locationInfo = locationInfo, + onLocationLockClicked = { locationLockClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.recenter)).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_NOT_LOCKED_TEST_TAG).assertIsDisplayed().performClick() + + assertThat(locationLockClicked).isTrue() } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt index 2ff42e2af7..bd4e95e4ad 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import com.google.common.truth.Truth.assertThat import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.ui.theme.AppTheme @@ -43,25 +44,69 @@ class LocationInfoCardTest { ) composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE) + .onNodeWithTag(CURRENT_LOCATION_TITLE_TEST_TAG) .assertIsDisplayed() .assertTextEquals(getString(R.string.current_location)) composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE) + .onNodeWithTag(CURRENT_LOCATION_VALUE_TEST_TAG) .assertIsDisplayed() .assertTextEquals("29º58’15” N 114º36’17”W") composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.ACCURACY_TITLE) + .onNodeWithTag(ACCURACY_TITLE_TEST_TAG) .assertIsDisplayed() .assertTextEquals(getString(R.string.accuracy)) composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.ACCURACY_VALUE) + .onNodeWithTag(ACCURACY_VALUE_TEST_TAG) .assertIsDisplayed() .assertTextEquals("3m") } + @Test + fun `Location info card is displayed with poor accuracy when isAccuracyGood is false`() { + val locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "25m", + isAccuracyGood = false, + ) + composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } + + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() + composeTestRule + .onNodeWithTag(CURRENT_LOCATION_TITLE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.current_location)) + composeTestRule + .onNodeWithTag(CURRENT_LOCATION_VALUE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + composeTestRule + .onNodeWithTag(ACCURACY_TITLE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.accuracy)) + composeTestRule + .onNodeWithTag(ACCURACY_VALUE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals("25m") + } + + @Test + fun `Location info card displays question mark when accuracy is unknown`() { + val locationInfo = + LocationInfo( + titleRes = R.string.current_location, + locationText = "29º58’15” N 114º36’17”W", + accuracyText = "?", + isAccuracyGood = false, + ) + composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } + + composeTestRule.onNodeWithTag(ACCURACY_VALUE_TEST_TAG).assertIsDisplayed().assertTextEquals("?") + } + @Test fun `Location info card is displayed with map location and no accuracy when accuracy is null`() { val locationInfo = @@ -72,16 +117,28 @@ class LocationInfoCardTest { ) composeTestRule.setContent { AppTheme { LocationInfoCard(locationInfo = locationInfo) } } - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.LOCATION_INFO_CARD).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_TITLE) + .onNodeWithTag(CURRENT_LOCATION_TITLE_TEST_TAG) .assertIsDisplayed() .assertTextEquals(getString(R.string.map_location)) composeTestRule - .onNodeWithTag(LocationInfoCardTestTags.CURRENT_LOCATION_VALUE) + .onNodeWithTag(CURRENT_LOCATION_VALUE_TEST_TAG) .assertIsDisplayed() .assertTextEquals("29º58’15” N 114º36’17”W") - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.ACCURACY_TITLE).assertDoesNotExist() - composeTestRule.onNodeWithTag(LocationInfoCardTestTags.ACCURACY_VALUE).assertDoesNotExist() + composeTestRule.onNodeWithTag(ACCURACY_TITLE_TEST_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(ACCURACY_VALUE_TEST_TAG).assertDoesNotExist() + } + + @Test + fun `LocationInfo constructor defaults accuracyText to null and isAccuracyGood to false`() { + val locationInfo = + LocationInfo( + titleRes = R.string.map_location, + locationText = "10º00’00” N 20º00’00”W", + ) + + assertThat(locationInfo.accuracyText).isNull() + assertThat(locationInfo.isAccuracyGood).isFalse() } } From fdeb4d2ddb065c9c3f3cf00cb1b92d0136213cd1 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Thu, 10 Sep 2026 09:51:20 +0530 Subject: [PATCH 4/4] refactor: update location info card UI and optimize location state collection with combine operator --- .../tasks/AbstractTaskMapFragment.kt | 33 +++++++++++-------- .../tasks/map/components/LocationInfoCard.kt | 3 +- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt index ffd1fa4fa6..6613a5798a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskMapFragment.kt @@ -32,6 +32,7 @@ import java.math.RoundingMode import java.text.DecimalFormat import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.groundplatform.android.R @@ -114,21 +115,25 @@ abstract class AbstractTaskMapFragment : viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - getMapViewModel().location.collect { - if (getMapViewModel().locationLock.value.getOrDefault(false)) { - val locationText = it?.toCoordinates()?.toDmsFormat() - val accuracy = it?.getAccuracyOrNull() - val accuracyText = - accuracy?.let { value -> ACCURACY_FORMATTER.format(value) + "m" } ?: "?" - - updateLocationInfoCard( - R.string.current_location, - locationText, - accuracyText, - accuracy, - ) + combine(getMapViewModel().location, getMapViewModel().locationLock) { location, locationLock + -> + Pair(location, locationLock.getOrDefault(false)) + } + .collect { (location, isLocked) -> + if (isLocked) { + val locationText = location?.toCoordinates()?.toDmsFormat() + val accuracy = location?.getAccuracyOrNull() + val accuracyText = + accuracy?.let { value -> ACCURACY_FORMATTER.format(value) + "m" } ?: "?" + + updateLocationInfoCard( + R.string.current_location, + locationText, + accuracyText, + accuracy, + ) + } } - } } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt index 2ce7314462..8ad8d0aa79 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt @@ -62,6 +62,7 @@ fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) modifier = modifier.testTag(LOCATION_INFO_CARD_TEST_TAG), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = CardDefaults.outlinedCardBorder(), ) { Row( modifier = Modifier.fillMaxWidth().padding(8.dp), @@ -84,7 +85,7 @@ fun LocationInfoCard(locationInfo: LocationInfo, modifier: Modifier = Modifier) Text( text = locationInfo.locationText, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag(CURRENT_LOCATION_VALUE_TEST_TAG), ) }