From 544841d887416200927ae1c4ceb1a94be6de2a9a Mon Sep 17 00:00:00 2001 From: Scott Olsen Date: Fri, 1 Mar 2024 12:22:13 -0500 Subject: [PATCH] Home Screen: Show a snackbar hint on survey change (#2274) * Home Screen: Show a snackbar hint on survey change Displays a snackbar hint when a new survey is selected. The hint text varies based on the following survey properties: - If there is at least one job lacking the predefined data collection strategy. - If there are zero LOIs associated with the jobs. Otherwise, we display some default text. * Refactor EphemeralPopups Refactors the EphemeralPopups class to support both error and informational popups, wrapping underlying device classes. Also modifies our handling of informational popups in the homescreen, moving more data management and transformation operations into the viewmodel. * HomeScreenFragment: validate state of viewmodel and view... ...before calling showDataCollectionHint. Suppress Detekt Data Class warning on EphemeralPopups. * Fix call to EphemeralPopups.ErrorPopup.show * Small cleanup in EphemeralPopups --------- Co-authored-by: Gino Miceli <228050+gino-m@users.noreply.github.com> --- .../ground/ui/common/EphemeralPopups.kt | 58 ++++++++++++++++--- .../datacollection/DataCollectionViewModel.kt | 4 +- .../tasks/photo/PhotoTaskFragment.kt | 2 +- .../HomeScreenMapContainerFragment.kt | 35 +++++++++-- .../HomeScreenMapContainerViewModel.kt | 19 +++++- .../ground/ui/startup/StartupFragment.kt | 2 +- ground/src/main/res/values/strings.xml | 3 + 7 files changed, 106 insertions(+), 17 deletions(-) diff --git a/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt b/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt index 2baed30eb5..9c6bf6e1f9 100644 --- a/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt +++ b/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt @@ -16,25 +16,69 @@ package com.google.android.ground.ui.common import android.app.Application +import android.view.View import android.widget.Toast import androidx.annotation.StringRes import com.google.android.ground.R +import com.google.android.material.snackbar.Snackbar import javax.inject.Inject import javax.inject.Singleton /** Displays short-lived messages such as toasts that are shown over other UI elements. */ +@Suppress("UseDataClass") @Singleton class EphemeralPopups @Inject constructor(private val context: Application) { - fun showError(@StringRes messageId: Int) = showLong(messageId) + enum class PopupDuration { + SHORT, + LONG, + INDEFINITE, + } - fun showError(message: String) = showLong(message) + /** Defines functions to render a popup that displays an error message to the user. */ + inner class ErrorPopup { + fun show(@StringRes messageId: Int, duration: PopupDuration = PopupDuration.LONG) = + showToast(messageId, duration) - // TODO: Rename to unknownError? - fun showError() = showLong(R.string.unexpected_error) + fun show(message: String, duration: PopupDuration = PopupDuration.LONG) = + showToast(message, duration) - private fun showLong(@StringRes messageId: Int) = - Toast.makeText(context, messageId, Toast.LENGTH_LONG).show() + fun unknownError() = showToast(R.string.unexpected_error, duration = PopupDuration.LONG) - private fun showLong(message: String) = Toast.makeText(context, message, Toast.LENGTH_LONG).show() + private fun showToast(@StringRes messageId: Int, duration: PopupDuration) = + showToast(context.getString(messageId), duration) + + private fun showToast(message: String, duration: PopupDuration) { + val dur = + if (duration == PopupDuration.SHORT) { + Toast.LENGTH_SHORT + } else { + // INDEFINITE Length is not supported for toasts; we just use LONG instead for now. + Toast.LENGTH_LONG + } + Toast.makeText(context, message, dur).show() + } + } + + /** Defines functions to render a popup that displays an informational message to the user. */ + inner class InfoPopup { + fun show( + view: View, + @StringRes messageId: Int, + duration: PopupDuration = PopupDuration.INDEFINITE, + ) { + val msg = context.resources.getString(messageId) + showSnackbar(view, msg, duration) + } + + private fun showSnackbar(view: View, msg: String, duration: PopupDuration) { + val dur = + when (duration) { + PopupDuration.SHORT -> Snackbar.LENGTH_SHORT + PopupDuration.LONG -> Snackbar.LENGTH_LONG + PopupDuration.INDEFINITE -> Snackbar.LENGTH_INDEFINITE + } + Snackbar.make(view, msg, dur).show() + } + } } diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt index c860ae776b..dd5f8e1331 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt @@ -147,7 +147,7 @@ internal constructor( val validationError = taskViewModel.validate() if (validationError != null) { - popups.get().showError(validationError) + popups.get().ErrorPopup().show(validationError) return } @@ -161,7 +161,7 @@ internal constructor( suspend fun onNextClicked(position: Int, taskViewModel: AbstractTaskViewModel) { val validationError = taskViewModel.validate() if (validationError != null) { - popups.get().showError(validationError) + popups.get().ErrorPopup().show(validationError) return } diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/photo/PhotoTaskFragment.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/photo/PhotoTaskFragment.kt index 9cc96c19b2..2727d732be 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/photo/PhotoTaskFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/photo/PhotoTaskFragment.kt @@ -188,7 +188,7 @@ class PhotoTaskFragment : AbstractTaskFragment() { capturePhotoLauncher.launch(uri) Timber.d("Capture photo intent sent") } catch (e: IllegalArgumentException) { - popups.showError(R.string.error_message) + popups.ErrorPopup().show(R.string.error_message) Timber.e(e) } } diff --git a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt index 6a2f216a99..8f63a0ac4e 100644 --- a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt @@ -82,7 +82,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } else { // Skip data collection screen if the user can't submit any data // TODO(#1667): Revisit UX for displaying view only mode - ephemeralPopups.showError(getString(R.string.collect_data_viewer_error)) + ephemeralPopups.ErrorPopup().show(getString(R.string.collect_data_viewer_error)) } } } @@ -115,7 +115,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { binding = BasemapLayoutBinding.inflate(inflater, container, false) binding.fragment = this @@ -128,6 +128,33 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { super.onViewCreated(view, savedInstanceState) setupMenuFab() setupBottomLoiCards() + lifecycleScope.launch { showDataCollectionHint() } + } + + /** + * Displays a popup hint informing users how to begin collecting data, based on the properties of + * the active survey. + * + * This method should only be called after view creation. + */ + private suspend fun showDataCollectionHint() { + check(this::mapContainerViewModel.isInitialized) { + "showDataCollectionHint called before mapContainerViewModel was initialized" + } + check(this::binding.isInitialized) { + "showDataCollectionHint called before binding was initialized" + } + mapContainerViewModel.surveyUpdateFlow.collect { + val messageId = + when { + it.addLoiPermitted -> R.string.suggest_data_collection_hint + it.readOnly -> R.string.read_only_data_collection_hint + else -> R.string.predefined_data_collection_hint + } + ephemeralPopups + .InfoPopup() + .show(binding.root, messageId, EphemeralPopups.PopupDuration.INDEFINITE) + } } private fun setupMenuFab() { @@ -183,14 +210,14 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { navigator.navigate( HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment( cardUiData.loi.id, - cardUiData.loi.job.id + cardUiData.loi.job.id, ) ) is MapCardUiData.AddLoiCardUiData -> navigator.navigate( HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment( null, - cardUiData.job.id + cardUiData.job.id, ) ) } diff --git a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index a49cc3c5a7..af42275831 100644 --- a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -78,6 +80,21 @@ internal constructor( private val selectedLoiIdFlow = MutableStateFlow(null) + val activeSurvey: StateFlow = surveyRepository.activeSurveyFlow + + /** Captures essential, high-level derived properties for a given survey. */ + data class SurveyProperties(val addLoiPermitted: Boolean, val readOnly: Boolean) + + /** + * This flow emits [SurveyProperties] when the active survey changes. Callers can use this data to + * determine if and how behavior should change based on differing survey properties. + */ + val surveyUpdateFlow: Flow = + activeSurvey.filterNotNull().map { + val lois = loiRepository.getLocationsOfInterests(it).first() + SurveyProperties(it.jobs.any { it.canDataCollectorsAddLois }, lois.isEmpty()) + } + /** Set of [Feature] to render on the map. */ val mapLoiFeatures: Flow> @@ -105,8 +122,6 @@ internal constructor( // TODO: Since we depend on survey stream from repo anyway, this transformation can be moved // into the repository. - val activeSurvey = surveyRepository.activeSurveyFlow - mapLoiFeatures = activeSurvey.flatMapLatest { if (it == null) flowOf(setOf()) diff --git a/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt b/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt index 6986de79a7..648118f537 100644 --- a/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/startup/StartupFragment.kt @@ -67,7 +67,7 @@ class StartupFragment : AbstractFragment() { private fun onInitFailed(t: Throwable) { Timber.e(t, "Failed to launch app") if (t is GoogleApiManager.GooglePlayServicesMissingException) { - popups.showError(R.string.google_api_install_failed) + popups.ErrorPopup().show(R.string.google_api_install_failed) } requireActivity().finish() } diff --git a/ground/src/main/res/values/strings.xml b/ground/src/main/res/values/strings.xml index c95a8b7ca8..3b350aea48 100644 --- a/ground/src/main/res/values/strings.xml +++ b/ground/src/main/res/values/strings.xml @@ -136,4 +136,7 @@ Map location Unsynced data If you sign out, any unsynced data will be discarded + Zoom in to start collecting data + Survey is read-only + Zoom in to a data collection site to collect data