diff --git a/.circleci/config.yml b/.circleci/config.yml index 531ce68d0b3..c80562ac3e4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -266,7 +266,7 @@ jobs: if [[ -n "$GOOGLE_MAPS_API_KEY" ]]; then \ ./check-size.sh 23117869 else - ./check-size.sh 13841204 + ./check-size.sh 13853041 fi - run: diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/InternalRecordingRequesterTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/InternalRecordingRequesterTest.java index 9bc46d1a569..5adfff4b851 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/InternalRecordingRequesterTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/InternalRecordingRequesterTest.java @@ -2,6 +2,7 @@ import androidx.activity.ComponentActivity; import androidx.lifecycle.MutableLiveData; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.javarosa.form.api.FormEntryPrompt; @@ -31,6 +32,7 @@ public class InternalRecordingRequesterTest { @Before public void setup() { + ApplicationProvider.getApplicationContext().setTheme(com.google.android.material.R.style.Theme_MaterialComponents); ComponentActivity activity = Robolectric.buildActivity(ComponentActivity.class).get(); when(audioRecorder.getCurrentSession()).thenReturn(new MutableLiveData<>(null)); diff --git a/geo/build.gradle.kts b/geo/build.gradle.kts index b75c45dadba..d93320ddaea 100644 --- a/geo/build.gradle.kts +++ b/geo/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) alias(libs.plugins.kotlinKapt) + alias(libs.plugins.composeCompiler) } apply(from = "../config/quality.gradle") @@ -69,6 +70,11 @@ dependencies { exclude(group = "org.hamcrest", module = "hamcrest-all") } + implementation(libs.androidXComposeMaterial) + implementation(libs.androidXComposeMaterialIcons) + implementation(libs.androidXComposePreview) + debugImplementation(libs.androidXComposeTooling) + debugImplementation(project(":fragments-test")) testImplementation(project(":androidtest")) @@ -81,4 +87,6 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidxTestEspressoCore) testImplementation(libs.androidxArchCoreTesting) + testImplementation(libs.androidXComposeUiTestJunit4) + debugImplementation(libs.androidXComposeUiTestManifest) } diff --git a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyFragment.kt b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyFragment.kt index f1097ff9b3f..23bb81e41c7 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyFragment.kt +++ b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyFragment.kt @@ -40,6 +40,7 @@ import org.odk.collect.maps.PolygonDescription import org.odk.collect.maps.layers.OfflineMapLayersPickerBottomSheetDialogFragment import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.settings.SettingsProvider +import org.odk.collect.strings.R.string import org.odk.collect.webpage.WebPageService import javax.inject.Inject @@ -75,15 +76,7 @@ class GeoPolyFragment @JvmOverloads constructor( private var map: MapFragment? = null private var featureId = -1 // will be a positive featureId once map is ready private var originalPoly: List? = null - - private var inputActive = false // whether we are ready for the user to add points - private var recordingEnabled = - false // whether points are taken from GPS readings (if not, placed by tapping) - private var recordingAutomatic = - false // whether GPS readings are taken at regular intervals (if not, only when user-directed) - private var intervalIndex: Int = DEFAULT_INTERVAL_INDEX - private var accuracyThresholdIndex: Int = DEFAULT_ACCURACY_THRESHOLD_INDEX private val onBackPressedCallback: OnBackPressedCallback = @@ -143,9 +136,6 @@ class GeoPolyFragment @JvmOverloads constructor( previousState = savedInstanceState if (savedInstanceState != null) { - inputActive = savedInstanceState.getBoolean(INPUT_ACTIVE_KEY, false) - recordingEnabled = savedInstanceState.getBoolean(RECORDING_ENABLED_KEY, false) - recordingAutomatic = savedInstanceState.getBoolean(RECORDING_AUTOMATIC_KEY, false) intervalIndex = savedInstanceState.getInt(INTERVAL_INDEX_KEY, DEFAULT_INTERVAL_INDEX) accuracyThresholdIndex = savedInstanceState.getInt( ACCURACY_THRESHOLD_INDEX_KEY, DEFAULT_ACCURACY_THRESHOLD_INDEX @@ -172,9 +162,6 @@ class GeoPolyFragment @JvmOverloads constructor( } return } - state.putBoolean(INPUT_ACTIVE_KEY, inputActive) - state.putBoolean(RECORDING_ENABLED_KEY, recordingEnabled) - state.putBoolean(RECORDING_AUTOMATIC_KEY, recordingAutomatic) state.putInt(INTERVAL_INDEX_KEY, intervalIndex) state.putInt(ACCURACY_THRESHOLD_INDEX_KEY, accuracyThresholdIndex) } @@ -182,10 +169,10 @@ class GeoPolyFragment @JvmOverloads constructor( fun initMap(newMapFragment: MapFragment?, binding: GeopolyLayoutBinding) { map = newMapFragment + binding.info.setOnClickListener { showInfoDialog(false) } binding.clear.setOnClickListener { showClearDialog() } binding.pause.setOnClickListener { viewModel.stopRecording() - inputActive = false updateUi() } @@ -247,7 +234,14 @@ class GeoPolyFragment @JvmOverloads constructor( } } - val snackbar = SnackbarUtils.make(requireView(), "", Snackbar.LENGTH_INDEFINITE) + val snackbar = SnackbarUtils.make( + requireView(), + "", + Snackbar.LENGTH_INDEFINITE, + action = SnackbarUtils.Action(getString(string.how_to_modify)) { + showInfoDialog(true) + } + ) val viewData = viewModel.points.asLiveData().zip(invalidMessage) viewData.observe(viewLifecycleOwner) { (points, invalidMessage) -> val isValid = invalidMessage == null @@ -354,8 +348,8 @@ class GeoPolyFragment @JvmOverloads constructor( } override fun startInput() { - inputActive = true - if (recordingEnabled && recordingAutomatic) { + viewModel.enableInput() + if (viewModel.recordingMode == GeoPolyViewModel.RecordingMode.AUTOMATIC) { locationTracker.warm(map!!.getGpsLocation()?.toLocation()) viewModel.startRecording( ACCURACY_THRESHOLD_OPTIONS[accuracyThresholdIndex], @@ -366,15 +360,18 @@ class GeoPolyFragment @JvmOverloads constructor( } override fun updateRecordingMode(id: Int) { - recordingEnabled = id != R.id.placement_mode - recordingAutomatic = id == R.id.automatic_mode + when (id) { + R.id.placement_mode -> viewModel.setRecordingMode(GeoPolyViewModel.RecordingMode.PLACEMENT) + R.id.manual_mode -> viewModel.setRecordingMode(GeoPolyViewModel.RecordingMode.MANUAL) + R.id.automatic_mode -> viewModel.setRecordingMode(GeoPolyViewModel.RecordingMode.AUTOMATIC) + } } override fun getCheckedId(): Int { - return if (recordingEnabled) { - if (recordingAutomatic) R.id.automatic_mode else R.id.manual_mode - } else { - R.id.placement_mode + return when (viewModel.recordingMode) { + GeoPolyViewModel.RecordingMode.PLACEMENT -> R.id.placement_mode + GeoPolyViewModel.RecordingMode.MANUAL -> R.id.manual_mode + GeoPolyViewModel.RecordingMode.AUTOMATIC -> R.id.automatic_mode } } @@ -410,21 +407,21 @@ class GeoPolyFragment @JvmOverloads constructor( } private fun onClick(point: MapPoint) { - if (inputActive && !recordingEnabled) { + if (viewModel.inputActive && viewModel.recordingMode == GeoPolyViewModel.RecordingMode.PLACEMENT) { viewModel.add(point) } } private fun onGpsLocationReady(map: MapFragment) { // Don't zoom to current location if a user is manually entering points - if (requireActivity().window.isActive && (!inputActive || recordingEnabled)) { + if (requireActivity().window.isActive && (!viewModel.inputActive || viewModel.recordingMode != GeoPolyViewModel.RecordingMode.PLACEMENT)) { map.zoomToCurrentLocation(map.getGpsLocation()) } updateUi() } private fun onGpsLocation(point: MapPoint?) { - if (inputActive && recordingEnabled) { + if (viewModel.inputActive && viewModel.recordingMode != GeoPolyViewModel.RecordingMode.PLACEMENT) { map!!.setCenter(point, false) } updateUi() @@ -447,7 +444,7 @@ class GeoPolyFragment @JvmOverloads constructor( get() { val meters: Int = ACCURACY_THRESHOLD_OPTIONS[accuracyThresholdIndex] - return recordingEnabled && recordingAutomatic && meters > 0 + return viewModel.recordingMode == GeoPolyViewModel.RecordingMode.AUTOMATIC && meters > 0 } private fun removeLastPoint() { @@ -457,7 +454,7 @@ class GeoPolyFragment @JvmOverloads constructor( } private fun clear() { - inputActive = false + viewModel.disableInput() viewModel.update(emptyList()) } @@ -469,14 +466,14 @@ class GeoPolyFragment @JvmOverloads constructor( val location = map!!.getGpsLocation() // Visibility state - binding.play.isVisible = !inputActive - binding.pause.isVisible = inputActive - binding.recordButton.isVisible = inputActive && recordingEnabled && !recordingAutomatic + binding.play.isVisible = !viewModel.inputActive + binding.pause.isVisible = viewModel.inputActive + binding.recordButton.isVisible = viewModel.inputActive && viewModel.recordingMode == GeoPolyViewModel.RecordingMode.MANUAL // Enabled state binding.zoom.isEnabled = location != null binding.backspace.isEnabled = numPoints > 0 - binding.clear.isEnabled = !inputActive && numPoints > 0 + binding.clear.isEnabled = !viewModel.inputActive && numPoints > 0 if (readOnly) { binding.play.isEnabled = false @@ -501,16 +498,16 @@ class GeoPolyFragment @JvmOverloads constructor( } } - binding.collectionStatus.text = if (!inputActive) { + binding.collectionStatus.text = if (!viewModel.inputActive) { getString(org.odk.collect.strings.R.string.collection_status_paused, numPoints) } else { - if (!recordingEnabled) { + if (viewModel.recordingMode == GeoPolyViewModel.RecordingMode.PLACEMENT) { getString( org.odk.collect.strings.R.string.collection_status_placement, numPoints ) } else { - if (!recordingAutomatic) { + if (viewModel.recordingMode == GeoPolyViewModel.RecordingMode.MANUAL) { getString( org.odk.collect.strings.R.string.collection_status_manual, numPoints @@ -552,6 +549,10 @@ class GeoPolyFragment @JvmOverloads constructor( } } + private fun showInfoDialog(fromSnackbar: Boolean) { + InfoDialog.show(requireContext(), viewModel, fromSnackbar) + } + private fun showClearDialog() { if (!viewModel.points.value.isEmpty()) { MaterialAlertDialogBuilder(requireContext()) @@ -578,10 +579,6 @@ class GeoPolyFragment @JvmOverloads constructor( const val REQUEST_GEOPOLY: String = "geopoly" const val RESULT_GEOPOLY: String = "geopoly" const val RESULT_GEOPOLY_CHANGE: String = "geopoly_change" - - const val INPUT_ACTIVE_KEY: String = "input_active" - const val RECORDING_ENABLED_KEY: String = "recording_enabled" - const val RECORDING_AUTOMATIC_KEY: String = "recording_automatic" const val INTERVAL_INDEX_KEY: String = "interval_index" const val ACCURACY_THRESHOLD_INDEX_KEY: String = "accuracy_threshold_index" val INTERVAL_OPTIONS = intArrayOf( diff --git a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyViewModel.kt b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyViewModel.kt index bf0e51ecfb1..5b65e9975a2 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyViewModel.kt +++ b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyViewModel.kt @@ -17,6 +17,16 @@ class GeoPolyViewModel( private val scheduler: Scheduler ) : ViewModel() { + enum class RecordingMode { + PLACEMENT, MANUAL, AUTOMATIC + } + + var recordingMode: RecordingMode = RecordingMode.PLACEMENT + private set + + var inputActive: Boolean = false + private set + private val _points = MutableStateFlow( if (points.isNotEmpty()) { if (outputMode == OutputMode.GEOSHAPE) { @@ -69,10 +79,23 @@ class GeoPolyViewModel( } fun stopRecording() { + disableInput() recording?.cancel() locationTracker.stop() } + fun setRecordingMode(mode: RecordingMode) { + recordingMode = mode + } + + fun enableInput() { + inputActive = true + } + + fun disableInput() { + inputActive = false + } + override fun onCleared() { stopRecording() } diff --git a/geo/src/main/java/org/odk/collect/geo/geopoly/InfoDialog.kt b/geo/src/main/java/org/odk/collect/geo/geopoly/InfoDialog.kt new file mode 100644 index 00000000000..7bc921d7938 --- /dev/null +++ b/geo/src/main/java/org/odk/collect/geo/geopoly/InfoDialog.kt @@ -0,0 +1,165 @@ +package org.odk.collect.geo.geopoly + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material.icons.automirrored.filled.DirectionsWalk +import androidx.compose.material.icons.filled.AddLocation +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.androidshared.R.dimen +import org.odk.collect.androidshared.ui.ComposeThemeProvider.Companion.setContextThemedContent +import org.odk.collect.strings.R.string + +object InfoDialog { + data class InfoItem( + val icon: ImageVector, + val text: String + ) + + fun show(context: Context, viewModel: GeoPolyViewModel, fromSnackbar: Boolean) { + var dialog: AlertDialog? = null + + val info = ComposeView(context).apply { + setContextThemedContent { + InfoContent(viewModel, fromSnackbar) { dialog?.dismiss() } + } + } + + dialog = MaterialAlertDialogBuilder(context) + .setView(info) + .show() + } +} + +@Composable +fun InfoContent( + viewModel: GeoPolyViewModel, + fromSnackbar: Boolean, + onDone: () -> Unit +) { + val items = when (viewModel.recordingMode) { + GeoPolyViewModel.RecordingMode.PLACEMENT -> { + if (fromSnackbar) { + listOf( + InfoDialog.InfoItem(Icons.Filled.TouchApp, stringResource(string.long_press_to_move_point_info_item)), + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.Backspace, stringResource(string.remove_last_point_info_item)), + InfoDialog.InfoItem(Icons.Filled.Delete, stringResource(string.delete_shape_to_start_over_info_item)), + InfoDialog.InfoItem(Icons.Filled.AddLocation, stringResource(string.add_point_info_item)) + ) + } else { + listOf( + InfoDialog.InfoItem(Icons.Filled.AddLocation, stringResource(string.tap_to_add_a_point_info_item)), + InfoDialog.InfoItem(Icons.Filled.TouchApp, stringResource(string.long_press_to_move_point_info_item)), + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.Backspace, stringResource(string.remove_last_point_info_item)), + InfoDialog.InfoItem(Icons.Filled.Delete, stringResource(string.delete_entire_shape_info_item)) + ) + } + } + GeoPolyViewModel.RecordingMode.MANUAL, GeoPolyViewModel.RecordingMode.AUTOMATIC -> { + if (fromSnackbar) { + listOf( + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.DirectionsWalk, stringResource(string.physically_move_to_correct_info_item)), + InfoDialog.InfoItem(Icons.Filled.TouchApp, stringResource(string.long_press_to_move_point_info_item)), + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.Backspace, stringResource(string.remove_last_point_info_item)), + InfoDialog.InfoItem(Icons.Filled.Delete, stringResource(string.delete_entire_shape_info_item)), + ) + } else { + listOf( + InfoDialog.InfoItem(Icons.Filled.AddLocation, stringResource(string.tap_to_add_a_point_info_item)), + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.DirectionsWalk, stringResource(string.physically_move_to_correct_info_item)), + InfoDialog.InfoItem(Icons.Filled.TouchApp, stringResource(string.long_press_to_move_point_info_item)), + InfoDialog.InfoItem(Icons.AutoMirrored.Filled.Backspace, stringResource(string.remove_last_point_info_item)), + InfoDialog.InfoItem(Icons.Filled.Delete, stringResource(string.delete_entire_shape_info_item)) + ) + } + } + } + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(dimensionResource(id = dimen.margin_standard)) + .verticalScroll(scrollState) + ) { + Title() + items.forEachIndexed { index, item -> + Info(item.icon, item.text) + if (index < items.lastIndex) { + HorizontalDivider( + Modifier.padding(horizontal = dimensionResource(id = dimen.margin_small)) + ) + } + } + DoneButton(onDone) + } +} + +@Composable +private fun Title() { + Text( + modifier = Modifier.padding( + start = dimensionResource(id = dimen.margin_standard), + top = dimensionResource(id = dimen.margin_extra_small), + bottom = dimensionResource(id = dimen.margin_standard) + ), + text = stringResource(string.how_to_modify_map), + style = MaterialTheme.typography.titleLarge + ) +} + +@Composable +private fun Info(icon: ImageVector, text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(id = dimen.margin_standard)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + Text( + modifier = Modifier.padding(start = dimensionResource(id = dimen.margin_small)), + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Composable +private fun DoneButton(onDone: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(id = dimen.margin_standard)), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDone) { + Text(stringResource(string.done)) + } + } +} diff --git a/geo/src/main/res/drawable/ic_info.xml b/geo/src/main/res/drawable/ic_info.xml new file mode 100644 index 00000000000..ad9feb426df --- /dev/null +++ b/geo/src/main/res/drawable/ic_info.xml @@ -0,0 +1,11 @@ + + + + diff --git a/geo/src/main/res/layout-land/geopoly_layout.xml b/geo/src/main/res/layout-land/geopoly_layout.xml index 11dcb8a5acc..c703fa5b566 100644 --- a/geo/src/main/res/layout-land/geopoly_layout.xml +++ b/geo/src/main/res/layout-land/geopoly_layout.xml @@ -59,6 +59,19 @@ app:layout_constraintTop_toBottomOf="@id/zoom" app:srcCompat="@drawable/ic_layers" /> + + + + () + + @Test + fun `shows dialog content from snackbar in PLACEMENT mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.PLACEMENT) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = true) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_shape_to_start_over_info_item, + org.odk.collect.strings.R.string.add_point_info_item, + ) + ) + } + + @Test + fun `shows dialog content from info button in PLACEMENT mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.PLACEMENT) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = false) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.tap_to_add_a_point_info_item, + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_entire_shape_info_item, + ) + ) + } + + @Test + fun `shows dialog content from snackbar in MANUAL mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.MANUAL) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = true) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.physically_move_to_correct_info_item, + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_entire_shape_info_item, + ) + ) + } + + @Test + fun `shows dialog content from info button in MANUAL mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.MANUAL) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = false) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.tap_to_add_a_point_info_item, + org.odk.collect.strings.R.string.physically_move_to_correct_info_item, + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_entire_shape_info_item, + ) + ) + } + + @Test + fun `shows dialog content from snackbar in AUTOMATIC mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.AUTOMATIC) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = true) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.physically_move_to_correct_info_item, + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_entire_shape_info_item, + ) + ) + } + + @Test + fun `shows dialog content from info button in AUTOMATIC mode`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.AUTOMATIC) + } + + composeTestRule.setContent { + InfoContent(viewModel, fromSnackbar = false) {} + } + + assertInfo( + listOf( + org.odk.collect.strings.R.string.tap_to_add_a_point_info_item, + org.odk.collect.strings.R.string.physically_move_to_correct_info_item, + org.odk.collect.strings.R.string.long_press_to_move_point_info_item, + org.odk.collect.strings.R.string.remove_last_point_info_item, + org.odk.collect.strings.R.string.delete_entire_shape_info_item, + ) + ) + } + + @Test + fun `calls onDone when Done button is clicked`() { + val viewModel = mock().apply { + whenever(recordingMode).thenReturn(GeoPolyViewModel.RecordingMode.PLACEMENT) + } + + var onDoneCalled = false + + composeTestRule.setContent { + InfoContent(viewModel, false) { onDoneCalled = true } + } + + composeTestRule + .onNodeWithText(context.getString(org.odk.collect.strings.R.string.done)) + .assertIsDisplayed() + .performClick() + + assertThat(onDoneCalled, equalTo(true)) + } + + private fun assertInfo(items: List) { + items.forEach { + composeTestRule + .onNodeWithText(context.getString(it)) + .performScrollTo() + .assertIsDisplayed() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5545d8a66f7..de7bc876832 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ androidXComposeMaterialIconsExtended = { group = "androidx.compose.material", na androidXComposePreview = { group = "androidx.compose.ui", name = "ui-tooling-preview"} androidXComposeTooling = { group = "androidx.compose.ui", name = "ui-tooling"} androidXComposeUiTestJunit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"} +androidXComposeUiTestManifest = { group = "androidx.compose.ui", name = "ui-test-manifest"} androidXConstraintLayoutCompose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version = "1.1.1" } androidXComposeMaterialIcons = { group = "androidx.compose.material", name = "material-icons-extended", version ="1.7.8"} playServicesMaps = { group = "com.google.android.gms", name = "play-services-maps", version = "19.2.0" } diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index d6c30634e87..0a43ff4be87 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -683,6 +683,39 @@ Remove last point + + Show how to modify the map + + + How to modify the map + + + Long press to move point + + + Remove last point + + + Delete shape to start over + + + Add point + + + Tap to add a point + + + Delete entire shape + + + Physically move to correct + + + Done + + + How to modify +