From 1663746b138dcf9450bc1e032015e9b78704e438 Mon Sep 17 00:00:00 2001 From: Sufyan Abbasi Date: Thu, 14 Nov 2024 16:10:13 -0800 Subject: [PATCH 01/17] Add new LOI flow (unstyled) --- .../HomeScreenMapContainerFragment.kt | 34 +---- .../HomeScreenMapContainerViewModel.kt | 31 ++--- .../mapcontainer/cards/JobSelectionDialog.kt | 85 +++++++++++++ .../home/mapcontainer/cards/MapCardAdapter.kt | 119 ++++++++++-------- .../ground/ui/map/gms/GoogleMapsFragment.kt | 2 + .../google/android/ground/ui/theme/Color.kt | 2 +- .../src/main/res/layout/add_loi_card_item.xml | 71 +++++------ ground/src/main/res/values/dimens.xml | 2 +- ground/src/main/res/values/strings.xml | 7 +- 9 files changed, 204 insertions(+), 149 deletions(-) create mode 100644 ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/JobSelectionDialog.kt 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 44880ed491..c60ec5f074 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 @@ -24,9 +24,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.lifecycleScope -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 @@ -105,8 +103,8 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } // Bind data for cards - mapContainerViewModel.getMapCardUiData().launchWhenStartedAndCollect { (mapCards, loiCount) -> - adapter.updateData(canUserSubmitData, mapCards, loiCount - 1) + mapContainerViewModel.getMapCardUiData().launchWhenStartedAndCollect { (loiCard, jobCards) -> + adapter.updateData(canUserSubmitData, loiCard, jobCards) } } @@ -259,37 +257,9 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { 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) { 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 b514e046ca..072370c43f 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 @@ -106,8 +106,8 @@ internal constructor( */ private val loisInViewport: StateFlow> - /** [LocationOfInterest] clicked by the user. */ - val loiClicks: MutableStateFlow = MutableStateFlow(null) + /** [Feature] clicked by the user. */ + val featureClicked: MutableStateFlow = MutableStateFlow(null) /** * List of [Job]s which allow LOIs to be added during field collection, populated only when zoomed @@ -175,12 +175,20 @@ internal constructor( * Returns a flow of [MapCardUiData] associated with the active survey's LOIs and adhoc jobs for * displaying the cards. */ - fun getMapCardUiData(): Flow, 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 getMapCardUiData(): + Flow>> = + combine(loisInViewport, featureClicked, adHocLoiJobs) { loisInView, feature, jobs -> + val loiCard = + loisInView + .filter { it.geometry == feature?.geometry } + .firstOrNull() + ?.let { MapCardUiData.LoiCardUiData(it) } + if (loiCard == null && feature != null) { + // The feature is not in view anymore. + featureClicked.value = null + } + val jobCard = jobs.map { MapCardUiData.AddLoiCardUiData(it) } + Pair(loiCard, jobCard) } private fun updatedLoiSelectedStates( @@ -198,12 +206,7 @@ internal constructor( * list of provided features is empty. */ fun onFeatureClicked(features: Set) { - 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) { diff --git a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/JobSelectionDialog.kt b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/JobSelectionDialog.kt new file mode 100644 index 0000000000..feb61b1fd6 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/JobSelectionDialog.kt @@ -0,0 +1,85 @@ +package com.google.android.ground.ui.home.mapcontainer.cards + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.google.android.ground.R + +@Composable +fun JobSelectionDialog( + selectedJobId: String, + jobs: List, + onJobSelection: (MapCardUiData.AddLoiCardUiData) -> Unit, + onConfirmRequest: (MapCardUiData.AddLoiCardUiData) -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + containerColor = MaterialTheme.colorScheme.surface, + onDismissRequest = onDismissRequest, + title = { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.add_site), fontSize = 5.em) + } + }, + text = { + Column { + Spacer(Modifier.height(16.dp)) + jobs.forEach { JobSelectionRow(it, onJobSelection, it.job.id === selectedJobId) } + } + }, + confirmButton = { + Button( + onClick = { jobs.find { it.job.id == selectedJobId }?.let { onConfirmRequest(it) } }, + contentPadding = PaddingValues(25.dp, 0.dp), + enabled = selectedJobId != "", + ) { + Text(stringResource(R.string.begin)) + } + }, + dismissButton = { + OutlinedButton(onClick = { onDismissRequest() }) { + Text(text = stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +fun JobSelectionRow( + job: MapCardUiData.AddLoiCardUiData, + onJobSelection: (MapCardUiData.AddLoiCardUiData) -> Unit, + selected: Boolean, +) { + Row(modifier = Modifier.fillMaxWidth().padding(8.dp).clickable { onJobSelection(job) }) { + Text(job.job.name ?: stringResource(R.string.unnamed_job), fontSize = 18.sp) + if (selected) { + Icon( + modifier = Modifier.size(25.dp), + painter = painterResource(id = R.drawable.baseline_check_24), + contentDescription = "selected", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/MapCardAdapter.kt b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/MapCardAdapter.kt index f7a8479270..867f4cf819 100644 --- a/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/MapCardAdapter.kt +++ b/ground/src/main/java/com/google/android/ground/ui/home/mapcontainer/cards/MapCardAdapter.kt @@ -20,13 +20,20 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.RecyclerView import com.google.android.ground.R import com.google.android.ground.databinding.AddLoiCardItemBinding import com.google.android.ground.databinding.LoiCardItemBinding -import com.google.android.ground.model.job.Job import com.google.android.ground.model.locationofinterest.LocationOfInterest import com.google.android.ground.ui.common.LocationOfInterestHelper +import com.google.android.ground.ui.theme.AppTheme /** * An implementation of [RecyclerView.Adapter] that associates [LocationOfInterest] data with the @@ -37,9 +44,8 @@ class MapCardAdapter( ) : RecyclerView.Adapter() { private var canUserSubmitData: Boolean = false - private var focusedIndex: Int = 0 - private var indexOfLastLoi: Int = -1 - private val itemsList: MutableList = mutableListOf() + private var activeLoi: MapCardUiData.LoiCardUiData? = null + private val newLoiJobs: MutableList = mutableListOf() private var cardFocusedListener: ((MapCardUiData?) -> Unit)? = null private lateinit var collectDataListener: (MapCardUiData) -> Unit @@ -58,44 +64,37 @@ class MapCardAdapter( } override fun getItemViewType(position: Int): Int = - if (position <= indexOfLastLoi) { + if (activeLoi != null) { R.layout.loi_card_item } else { + // Assume we don't render add LOI option unless we know the job allows it. R.layout.add_loi_card_item } /** Binds [LocationOfInterest] data to [LoiViewHolder] or [AddLoiCardViewHolder]. */ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val uiData = itemsList[position] - val cardHolder = bindViewHolder(uiData, holder) - if (focusedIndex == position) { - cardFocusedListener?.invoke(uiData) + val loi = activeLoi + if (loi != null) { + val cardHolder = bindLoiCardViewHolder(loi, holder) + cardHolder.setOnClickListener { collectDataListener(loi) } + } else { + bindAddLoiCardViewHolder(newLoiJobs, holder) { job -> collectDataListener(job) } } - cardHolder.setOnClickListener { collectDataListener(uiData) } } /** Returns the size of the list. */ - override fun getItemCount() = itemsList.size - - /** Updates the currently focused item. */ - fun focusItemAtIndex(newIndex: Int) { - if (newIndex < 0 || newIndex >= itemCount || focusedIndex == newIndex) return - - focusedIndex = newIndex - notifyDataSetChanged() - } + override fun getItemCount() = if (activeLoi != null || newLoiJobs.isNotEmpty()) 1 else 0 /** Overwrites existing cards. */ fun updateData( canUserSubmitData: Boolean, - newItemsList: List, - indexOfLastLoi: Int, + loiCard: MapCardUiData.LoiCardUiData?, + jobCards: List, ) { this.canUserSubmitData = canUserSubmitData - this.indexOfLastLoi = indexOfLastLoi - itemsList.clear() - itemsList.addAll(newItemsList) - focusedIndex = 0 + activeLoi = loiCard + newLoiJobs.clear() + newLoiJobs.addAll(jobCards) notifyDataSetChanged() } @@ -107,32 +106,18 @@ class MapCardAdapter( this.collectDataListener = listener } - private fun bindViewHolder( - uiData: MapCardUiData, + private fun bindLoiCardViewHolder( + loiData: MapCardUiData.LoiCardUiData, holder: RecyclerView.ViewHolder, - ): CardViewHolder = - when (uiData) { - is MapCardUiData.LoiCardUiData -> { - (holder as LoiViewHolder).apply { bind(canUserSubmitData, uiData.loi) } - } - is MapCardUiData.AddLoiCardUiData -> { - (holder as AddLoiCardViewHolder).apply { bind(canUserSubmitData, uiData.job) } - } - } + ): LoiViewHolder = (holder as LoiViewHolder).apply { bind(canUserSubmitData, loiData.loi) } - /** Returns index of job card with the given [LocationOfInterest]. */ - fun getIndex(loi: LocationOfInterest): Int { - for ((index, item) in itemsList.withIndex()) { - if (item is MapCardUiData.LoiCardUiData && item.loi == loi) { - return index - } - } - return -1 - } + private fun bindAddLoiCardViewHolder( + addLoiJobData: List, + holder: RecyclerView.ViewHolder, + callback: (MapCardUiData.AddLoiCardUiData) -> Unit, + ): AddLoiCardViewHolder = (holder as AddLoiCardViewHolder).apply { bind(addLoiJobData, callback) } - abstract class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - abstract fun setOnClickListener(callback: () -> Unit) - } + abstract class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {} /** View item representing the [LocationOfInterest] data in the list. */ class LoiViewHolder( @@ -152,7 +137,7 @@ class MapCardAdapter( } } - override fun setOnClickListener(callback: () -> Unit) { + fun setOnClickListener(callback: () -> Unit) { binding.collectData.setOnClickListener { callback() } } } @@ -160,17 +145,41 @@ class MapCardAdapter( /** View item representing the Add Loi Job data in the list. */ class AddLoiCardViewHolder(internal val binding: AddLoiCardItemBinding) : CardViewHolder(binding.root) { + private val jobDialogOpened = mutableStateOf(false) - fun bind(canUserSubmitData: Boolean, job: Job) { + fun bind( + jobs: List, + callback: (MapCardUiData.AddLoiCardUiData) -> Unit, + ) { with(binding) { - jobName.text = job.name - collectData.visibility = - if (canUserSubmitData && job.hasTasks()) View.VISIBLE else View.GONE + loiCard.setOnClickListener { + jobDialogOpened.value = true + (root as ViewGroup).addView( + ComposeView(root.context).apply { + setContent { AppTheme { ShowJobSelectionDialog(jobs, callback, jobDialogOpened) } } + } + ) + } } } - override fun setOnClickListener(callback: () -> Unit) { - binding.collectData.setOnClickListener { callback() } + @Composable + fun ShowJobSelectionDialog( + jobs: List, + callback: (MapCardUiData.AddLoiCardUiData) -> Unit, + jobDialogOpened: MutableState, + ) { + var selectedJobId by rememberSaveable { mutableStateOf(jobs[0].job.id) } + var openJobsDialog by rememberSaveable { jobDialogOpened } + if (openJobsDialog) { + JobSelectionDialog( + selectedJobId = selectedJobId, + jobs = jobs, + onJobSelection = { selectedJobId = it.job.id }, + onConfirmRequest = { callback(it) }, + onDismissRequest = { openJobsDialog = false }, + ) + } } } } diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt index 73e027db41..3b21e09f03 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt @@ -225,6 +225,8 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { val clickedPolygons = featureManager.getIntersectingPolygons(latLng) if (clickedPolygons.isNotEmpty()) { viewLifecycleOwner.lifecycleScope.launch { featureClicks.emit(clickedPolygons) } + } else { + viewLifecycleOwner.lifecycleScope.launch { featureClicks.emit(emptySet()) } } } diff --git a/ground/src/main/java/com/google/android/ground/ui/theme/Color.kt b/ground/src/main/java/com/google/android/ground/ui/theme/Color.kt index 3c20235c4d..e44ca53739 100644 --- a/ground/src/main/java/com/google/android/ground/ui/theme/Color.kt +++ b/ground/src/main/java/com/google/android/ground/ui/theme/Color.kt @@ -35,7 +35,7 @@ val md_theme_light_onError = Color(0xFFFFFFFF) val md_theme_light_onErrorContainer = Color(0xFF410002) val md_theme_light_background = Color(0xFFFCFDF7) val md_theme_light_onBackground = Color(0xFF1A1C19) -val md_theme_light_surface = Color(0xFFFCFDF7) +val md_theme_light_surface = Color(0xFFEDEEE9) val md_theme_light_onSurface = Color(0xFF1A1C19) val md_theme_light_surfaceVariant = Color(0xFFDDE5D9) val md_theme_light_onSurfaceVariant = Color(0xFF424940) diff --git a/ground/src/main/res/layout/add_loi_card_item.xml b/ground/src/main/res/layout/add_loi_card_item.xml index 7d810defae..e26f037652 100644 --- a/ground/src/main/res/layout/add_loi_card_item.xml +++ b/ground/src/main/res/layout/add_loi_card_item.xml @@ -22,64 +22,49 @@ - + android:layout_height="match_parent" + android:layout_gravity="fill" + android:gravity="fill" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingTop="12dp" + android:paddingEnd="18dp" + android:paddingBottom="4dp"> + android:layout_height="match_parent" + android:layout_marginEnd="8dp" + android:gravity="center" + android:text="@string/plus_sign" + android:textColor="?attr/colorOnSurface" + android:textSize="30sp" + tools:text="+" /> - -