Skip to content

Commit

Permalink
Factor out job cards into Composables for new data entry points (goog…
Browse files Browse the repository at this point in the history
…le#2892)

* Add new LOI flow (unstyled)

* Refactor out cards

* Refactor cards into Composables.

* Formatting.

* Address `checkCode` issues.

* Remove debug logs.

* Rename MapUIData and fields.

* Update strings.xml

* Update strings.xml

* Update strings.xml

Added "add_site" and two more strings (untranslated)

* Update strings.xml

Added "add_site" and two more strings (untranslated)

* Update strings.xml

Remaining translations added ("unnamed_job" and "job_site_icon").

* Update strings.xml

Remaining translations added ("unnamed_job" and "job_site_icon").

* Update strings.xml

Remaining translations added ("unnamed_job" and "job_site_icon").

* Add missing import

* Formatting

* Add tests for new job logic.

* Address review comments

* Fix survey activation in test

* Refactor ComposeView --> createComposeView, remove inner classes.

---------

Co-authored-by: Gino Miceli <[email protected]>
Co-authored-by: Jonas Spekker <[email protected]>
  • Loading branch information
3 people authored Feb 5, 2025
1 parent 9d951fa commit 06f4dd1
Show file tree
Hide file tree
Showing 23 changed files with 628 additions and 550 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,16 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SnapHelper
import com.google.android.ground.R
import com.google.android.ground.coroutines.ApplicationScope
import com.google.android.ground.coroutines.IoDispatcher
import com.google.android.ground.coroutines.MainDispatcher
import com.google.android.ground.databinding.BasemapLayoutBinding
import com.google.android.ground.databinding.LoiCardsRecyclerViewBinding
import com.google.android.ground.databinding.MenuButtonBinding
import com.google.android.ground.model.locationofinterest.LOI_NAME_PROPERTY
import com.google.android.ground.model.locationofinterest.LocationOfInterest
import com.google.android.ground.proto.Survey.DataSharingTerms
import com.google.android.ground.repository.SubmissionRepository
import com.google.android.ground.repository.UserRepository
Expand All @@ -45,8 +38,10 @@ import com.google.android.ground.ui.common.EphemeralPopups
import com.google.android.ground.ui.home.DataSharingTermsDialog
import com.google.android.ground.ui.home.HomeScreenFragmentDirections
import com.google.android.ground.ui.home.HomeScreenViewModel
import com.google.android.ground.ui.home.mapcontainer.cards.MapCardAdapter
import com.google.android.ground.ui.home.mapcontainer.cards.MapCardUiData
import com.google.android.ground.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData
import com.google.android.ground.ui.home.mapcontainer.jobs.DataCollectionEntryPointData
import com.google.android.ground.ui.home.mapcontainer.jobs.JobMapComposables
import com.google.android.ground.ui.home.mapcontainer.jobs.SelectedLoiSheetData
import com.google.android.ground.ui.map.MapFragment
import com.google.android.ground.util.renderComposableDialog
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -57,7 +52,7 @@ import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import timber.log.Timber

/** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */
Expand All @@ -74,55 +69,57 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel
private lateinit var homeScreenViewModel: HomeScreenViewModel
private lateinit var binding: BasemapLayoutBinding
private lateinit var adapter: MapCardAdapter
private lateinit var jobMapComposables: JobMapComposables
private lateinit var infoPopup: EphemeralPopups.InfoPopup

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mapContainerViewModel = getViewModel(HomeScreenMapContainerViewModel::class.java)
homeScreenViewModel = getViewModel(HomeScreenViewModel::class.java)
adapter = MapCardAdapter { loi, view -> updateSubmissionCount(loi, view) }
jobMapComposables = JobMapComposables { loi ->
submissionRepository.getTotalSubmissionCount(loi)
}

launchWhenStarted {
val canUserSubmitData = userRepository.canUserSubmitData()

// Handle collect button clicks
adapter.setCollectDataListener { mapCardUiData ->
jobMapComposables.setCollectDataListener { mapUiData ->
val job =
lifecycleScope.launch {
mapContainerViewModel.activeSurveyDataSharingTermsFlow.cancellable().collectLatest {
hasDataSharingTerms ->
onCollectData(
canUserSubmitData,
hasValidTasks(mapCardUiData),
hasValidTasks(mapUiData),
hasDataSharingTerms,
mapCardUiData,
mapUiData,
)
}
}
job.cancel()
}

// Bind data for cards
mapContainerViewModel.getMapCardUiData().launchWhenStartedAndCollect { (mapCards, loiCount) ->
adapter.updateData(canUserSubmitData, mapCards, loiCount - 1)
mapContainerViewModel.processDataCollectionEntryPoints().launchWhenStartedAndCollect {
(loiCard, jobCards) ->
runBlocking { jobMapComposables.updateData(canUserSubmitData, loiCard, jobCards) }
}
}

map.featureClicks.launchWhenStartedAndCollect { mapContainerViewModel.onFeatureClicked(it) }
}

private fun hasValidTasks(cardUiData: MapCardUiData) =
private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) =
when (cardUiData) {
// LOI tasks are filtered out of the tasks list for pre-defined tasks.
is MapCardUiData.LoiCardUiData ->
cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0
is MapCardUiData.AddLoiCardUiData -> cardUiData.job.tasks.values.isNotEmpty()
is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0
is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty()
}

@Composable
private fun ShowDataSharingTermsDialog(
cardUiData: MapCardUiData,
cardUiData: DataCollectionEntryPointData,
dataSharingTerms: DataSharingTerms,
) {
DataSharingTermsDialog(dataSharingTerms) {
Expand All @@ -137,7 +134,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
canUserSubmitData: Boolean,
hasTasks: Boolean,
hasDataSharingTerms: DataSharingTerms?,
cardUiData: MapCardUiData,
cardUiData: DataCollectionEntryPointData,
) {
if (!canUserSubmitData) {
// Skip data collection screen if the user can't submit any data
Expand Down Expand Up @@ -165,17 +162,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
navigateToDataCollectionFragment(cardUiData)
}

/** Updates the given [TextView] with the submission count for the given [LocationOfInterest]. */
private fun updateSubmissionCount(loi: LocationOfInterest, view: TextView) {
externalScope.launch {
val count = submissionRepository.getTotalSubmissionCount(loi)
val submissionText =
if (count == 0) resources.getString(R.string.no_submissions)
else resources.getQuantityString(R.plurals.submission_count, count, count)
withContext(mainDispatcher) { view.text = submissionText }
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
Expand All @@ -191,8 +177,19 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupMenuFab()
setupBottomLoiCards()
val menuBinding = setupMenuFab()
val onOpen = {
binding.mapTypeBtn.hide()
binding.locationLockBtn.hide()
menuBinding.hamburgerBtn.hide()
}
val onDismiss = {
binding.mapTypeBtn.show()
binding.locationLockBtn.show()
menuBinding.hamburgerBtn.show()
}
jobMapComposables.render(binding.bottomContainer, onOpen, onDismiss)
binding.bottomContainer.bringToFront()
showDataCollectionHint()
}

Expand Down Expand Up @@ -237,54 +234,17 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
}
}

private fun setupMenuFab() {
private fun setupMenuFab(): MenuButtonBinding {
val mapOverlay = binding.overlay
val menuBinding = MenuButtonBinding.inflate(layoutInflater, mapOverlay, true)
menuBinding.homeScreenViewModel = homeScreenViewModel
menuBinding.lifecycleOwner = this
return menuBinding
}

private fun setupBottomLoiCards() {
val container = binding.bottomContainer
val recyclerViewBinding = LoiCardsRecyclerViewBinding.inflate(layoutInflater, container, true)
val recyclerView = recyclerViewBinding.recyclerView
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val firstCompletelyVisiblePosition =
layoutManager.findFirstCompletelyVisibleItemPosition()
var midPosition = (firstVisiblePosition + lastVisiblePosition) / 2

// Focus the last card
if (firstCompletelyVisiblePosition > midPosition) {
midPosition = firstCompletelyVisiblePosition
}

adapter.focusItemAtIndex(midPosition)
}
}
)

val helper: SnapHelper = PagerSnapHelper()
helper.attachToRecyclerView(recyclerView)

mapContainerViewModel.loiClicks.launchWhenStartedAndCollect {
val index = it?.let { adapter.getIndex(it) } ?: -1
if (index != -1) {
recyclerView.scrollToPosition(index)
adapter.focusItemAtIndex(index)
}
}
}

private fun navigateToDataCollectionFragment(cardUiData: MapCardUiData) {
private fun navigateToDataCollectionFragment(cardUiData: DataCollectionEntryPointData) {
when (cardUiData) {
is MapCardUiData.LoiCardUiData ->
is SelectedLoiSheetData ->
findNavController()
.navigate(
HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment(
Expand All @@ -296,7 +256,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
"",
)
)
is MapCardUiData.AddLoiCardUiData ->
is AdHocDataCollectionButtonData ->
findNavController()
.navigate(
HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment(
Expand All @@ -314,13 +274,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
override fun onMapReady(map: MapFragment) {
mapContainerViewModel.mapLoiFeatures.launchWhenStartedAndCollect { map.setFeatures(it) }

adapter.setLoiCardFocusedListener {
when (it) {
is MapCardUiData.LoiCardUiData -> mapContainerViewModel.selectLocationOfInterest(it.loi.id)
is MapCardUiData.AddLoiCardUiData,
null -> mapContainerViewModel.selectLocationOfInterest(null)
}
}
jobMapComposables.setSelectedFeature { mapContainerViewModel.selectLocationOfInterest(it) }
}

override fun getMapViewModel(): BaseMapViewModel = mapContainerViewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import com.google.android.ground.system.PermissionsManager
import com.google.android.ground.system.SettingsManager
import com.google.android.ground.ui.common.BaseMapViewModel
import com.google.android.ground.ui.common.SharedViewModel
import com.google.android.ground.ui.home.mapcontainer.cards.MapCardUiData
import com.google.android.ground.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData
import com.google.android.ground.ui.home.mapcontainer.jobs.DataCollectionEntryPointData
import com.google.android.ground.ui.home.mapcontainer.jobs.SelectedLoiSheetData
import com.google.android.ground.ui.map.Feature
import com.google.android.ground.ui.map.FeatureType
import com.google.android.ground.ui.map.isLocationOfInterest
Expand Down Expand Up @@ -110,8 +112,8 @@ internal constructor(
*/
private val loisInViewport: StateFlow<List<LocationOfInterest>>

/** [LocationOfInterest] clicked by the user. */
val loiClicks: MutableStateFlow<LocationOfInterest?> = MutableStateFlow(null)
/** [Feature] clicked by the user. */
val featureClicked: MutableStateFlow<Feature?> = MutableStateFlow(null)

/**
* List of [Job]s which allow LOIs to be added during field collection, populated only when zoomed
Expand Down Expand Up @@ -178,15 +180,23 @@ internal constructor(
}

/**
* Returns a flow of [MapCardUiData] associated with the active survey's LOIs and adhoc jobs for
* displaying the cards.
* Returns a flow of [DataCollectionEntryPointData] associated with the active survey's LOIs and
* adhoc jobs for displaying the cards.
*/
fun getMapCardUiData(): Flow<Pair<List<MapCardUiData>, Int>> =
loisInViewport.combine(adHocLoiJobs) { lois, jobs ->
val loiCards = lois.map { MapCardUiData.LoiCardUiData(it) }
val jobCards = jobs.map { MapCardUiData.AddLoiCardUiData(it) }

Pair(loiCards + jobCards, lois.size)
fun processDataCollectionEntryPoints():
Flow<Pair<SelectedLoiSheetData?, List<AdHocDataCollectionButtonData>>> =
combine(loisInViewport, featureClicked, adHocLoiJobs) { loisInView, feature, jobs ->
val loiCard =
loisInView
.filter { it.geometry == feature?.geometry }
.firstOrNull()
?.let { SelectedLoiSheetData(it) }
if (loiCard == null && feature != null) {
// The feature is not in view anymore.
featureClicked.value = null
}
val jobCard = jobs.map { AdHocDataCollectionButtonData(it) }
Pair(loiCard, jobCard)
}

private fun updatedLoiSelectedStates(
Expand All @@ -204,12 +214,7 @@ internal constructor(
* list of provided features is empty.
*/
fun onFeatureClicked(features: Set<Feature>) {
val geometry = features.map { it.geometry }.minByOrNull { it.area } ?: return
for (loi in loisInViewport.value) {
if (loi.geometry == geometry) {
loiClicks.value = loi
}
}
featureClicked.value = features.minByOrNull { it.geometry.area }
}

suspend fun updateDataSharingConsent(dataSharingTerms: Boolean) {
Expand Down Expand Up @@ -242,5 +247,8 @@ internal constructor(

fun selectLocationOfInterest(id: String?) {
selectedLoiIdFlow.value = id
if (id == null) {
featureClicked.value = null
}
}
}
Loading

0 comments on commit 06f4dd1

Please sign in to comment.