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..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 @@ -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,17 +31,18 @@ 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.combine 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.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 @@ -58,8 +56,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) @@ -70,12 +66,15 @@ abstract class AbstractTaskMapFragment : dataCollectionViewModel.getTaskViewModel(taskId) as TVM } - private lateinit var viewModel: BaseMapViewModel - 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) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel = getViewModel(BaseMapViewModel::class.java) @@ -88,64 +87,63 @@ abstract class AbstractTaskMapFragment : ): View { super.onCreateView(inflater, container, savedInstanceState) - binding = MapTaskFragBinding.inflate(inflater, container, false) - setupMapActionButtons() - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - getMapViewModel().location.collect { - val locationText = it?.toCoordinates()?.toDmsFormat() - - val df = DecimalFormat("#.##") - df.roundingMode = RoundingMode.DOWN - val accuracy = it?.getAccuracyOrNull() - val accuracyText = accuracy?.let { value -> df.format(value) + "m" } ?: "?" - - updateLocationInfoCard(R.string.current_location, locationText, accuracyText, accuracy) - } - } - } - - return binding.root - } - - private fun setupMapActionButtons() { - binding.mapTypeBtn.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - MapFloatingActionButton( - type = MapFloatingActionButtonType.MapType, - onClick = { showMapTypeSelectorDialog() }, - ) - } - } - - binding.locationLockBtn.apply { + val binding = BasemapLayoutBinding.inflate(inflater, container, false) + binding.composeContent.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setComposableContent { val locationLockButton by viewModel.locationLockIconType.collectAsStateWithLifecycle() - - MapFloatingActionButton( - type = locationLockButton, - onClick = { viewModel.onLocationLockClick() }, + 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() }, ) } } - binding.recenterBtn.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - val shouldShowRecenter by viewModel.shouldShowRecenterButton.collectAsStateWithLifecycle() + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - if (shouldShowRecenter) - RecenterButton( - modifier = Modifier.padding(start = 20.dp), - onClick = { viewModel.onLocationLockClick() }, - ) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + 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, + ) + } + } } } } + override fun onDestroyView() { + super.onDestroyView() + _locationInfo.value = null + _isCenterMarkerVisible.value = true + } + override fun getMapViewModel(): BaseMapViewModel = viewModel @MustBeInvokedByOverriders @@ -171,34 +169,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 @@ -209,4 +197,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 new file mode 100644 index 0000000000..639ce8c1fd --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreen.kt @@ -0,0 +1,123 @@ +/* + * 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 + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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.android.ui.datacollection.tasks.map.components.LocationInfo +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfoCard +import org.groundplatform.ui.theme.AppTheme + +@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 + * button, location lock button, and location info card. + */ +@Composable +fun TaskMapScreen( + locationLockButtonType: MapFloatingActionButtonType, + shouldShowRecenter: Boolean, + isCenterMarkerVisible: Boolean, + locationInfo: LocationInfo?, + onMapTypeClicked: () -> Unit, + onLocationLockClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + 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(TASK_MAP_CENTER_MARKER_TEST_TAG), + ) + } + + 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) + } + } + } +} + +@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/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..8ad8d0aa79 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCard.kt @@ -0,0 +1,135 @@ +/* + * 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.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 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( + @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) { + val hasAccuracy = !locationInfo.accuracyText.isNullOrBlank() + + Card( + 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), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = + if (hasAccuracy) { + Modifier.weight(0.75f) + } else { + Modifier.weight(1f) + } + ) { + Text( + text = stringResource(locationInfo.titleRes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(CURRENT_LOCATION_TITLE_TEST_TAG), + ) + Text( + text = locationInfo.locationText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(CURRENT_LOCATION_VALUE_TEST_TAG), + ) + } + + if (hasAccuracy) { + Column(modifier = Modifier.weight(0.25f)) { + Text( + text = stringResource(R.string.accuracy), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ACCURACY_TITLE_TEST_TAG), + ) + Text( + text = locationInfo.accuracyText.orEmpty(), + style = MaterialTheme.typography.labelMedium, + color = + colorResource( + if (locationInfo.isAccuracyGood) { + R.color.accuracy_good + } else { + R.color.accuracy_bad + } + ), + modifier = Modifier.testTag(ACCURACY_VALUE_TEST_TAG), + ) + } + } + } + } +} + +@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/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/map/TaskMapScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt new file mode 100644 index 0000000000..1aa235d3e0 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/TaskMapScreenTest.kt @@ -0,0 +1,181 @@ +/* + * 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 + +import androidx.compose.ui.test.assertIsDisplayed +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.android.ui.datacollection.tasks.map.components.LOCATION_INFO_CARD_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.map.components.LocationInfo +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(TASK_MAP_CENTER_MARKER_TEST_TAG).assertIsDisplayed() + } + + @Test + fun `Center marker is not displayed when isCenterMarkerVisible is false`() { + setContent(isCenterMarkerVisible = false) + + composeTestRule.onNodeWithTag(TASK_MAP_CENTER_MARKER_TEST_TAG).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 and clicking triggers callback`() { + var locationLockClicked = false + setContent( + locationLockButtonType = MapFloatingActionButtonType.LocationLocked(), + onLocationLockClicked = { locationLockClicked = true }, + ) + + 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(LOCATION_INFO_CARD_TEST_TAG).assertDoesNotExist() + } + + @Test + fun `Location info card is displayed when locationInfo is not null`() { + 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(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 new file mode 100644 index 0000000000..bd4e95e4ad --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/map/components/LocationInfoCardTest.kt @@ -0,0 +1,144 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +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(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("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 = + 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(LOCATION_INFO_CARD_TEST_TAG).assertIsDisplayed() + composeTestRule + .onNodeWithTag(CURRENT_LOCATION_TITLE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals(getString(R.string.map_location)) + composeTestRule + .onNodeWithTag(CURRENT_LOCATION_VALUE_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals("29º58’15” N 114º36’17”W") + 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() + } +}