Skip to content

Commit

Permalink
Home Screen: Show a snackbar hint on survey change (#2274)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
scolsen and gino-m authored Mar 1, 2024
1 parent 2b18cbf commit 544841d
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ internal constructor(

val validationError = taskViewModel.validate()
if (validationError != null) {
popups.get().showError(validationError)
popups.get().ErrorPopup().show(validationError)
return
}

Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class PhotoTaskFragment : AbstractTaskFragment<PhotoTaskViewModel>() {
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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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,
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +80,21 @@ internal constructor(

private val selectedLoiIdFlow = MutableStateFlow<String?>(null)

val activeSurvey: StateFlow<Survey?> = 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<SurveyProperties> =
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<Set<Feature>>

Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions ground/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,7 @@
<string name="map_location">Map location</string>
<string name="sign_out_dialog_title">Unsynced data</string>
<string name="sign_out_dialog_body">If you sign out, any unsynced data will be discarded</string>
<string name="suggest_data_collection_hint">Zoom in to start collecting data</string>
<string name="read_only_data_collection_hint">Survey is read-only</string>
<string name="predefined_data_collection_hint">Zoom in to a data collection site to collect data</string>
</resources>

0 comments on commit 544841d

Please sign in to comment.