Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
Expand All @@ -33,17 +31,13 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.databinding.OfflineAreaSelectorFragBinding
import org.groundplatform.android.model.map.MapType
import org.groundplatform.android.ui.common.AbstractMapContainerFragment
import org.groundplatform.android.ui.common.BaseMapViewModel
import org.groundplatform.android.ui.common.EphemeralPopups
import org.groundplatform.android.ui.common.MapConfig
import org.groundplatform.android.ui.components.MapFloatingActionButton
import org.groundplatform.android.ui.home.mapcontainer.HomeScreenMapContainerViewModel
import org.groundplatform.android.ui.map.MapFragment
import org.groundplatform.android.util.renderComposableDialog
import org.groundplatform.android.util.setComposableContent

/** Map UI used to select areas for download and viewing offline. */
@AndroidEntryPoint
Expand All @@ -54,8 +48,6 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() {

@Inject lateinit var popups: EphemeralPopups

private lateinit var binding: OfflineAreaSelectorFragBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mapContainerViewModel = getViewModel(HomeScreenMapContainerViewModel::class.java)
Expand All @@ -68,30 +60,63 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() {
savedInstanceState: Bundle?,
): View {
super.onCreateView(inflater, container, savedInstanceState)
binding = OfflineAreaSelectorFragBinding.inflate(inflater, container, false)
binding.viewModel = viewModel
binding.lifecycleOwner = this
return binding.root
}
val root = android.widget.FrameLayout(requireContext())
root.layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.locationLockBtn.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setComposableContent {
val locationLockButton by viewModel.locationLockIconType.collectAsStateWithLifecycle()

MapFloatingActionButton(
type = locationLockButton,
onClick = { viewModel.onLocationLockClick() },
val mapContainer = androidx.fragment.app.FragmentContainerView(requireContext())
mapContainer.id = R.id.map
root.addView(
mapContainer,
android.widget.FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)

val composeView = androidx.compose.ui.platform.ComposeView(requireContext())
composeView.setViewCompositionStrategy(
androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
org.groundplatform.android.ui.theme.AppTheme {
val locationLockIcon by viewModel.locationLockIconType.collectAsStateWithLifecycle()
val bottomText by viewModel.bottomText.observeAsState("")
val downloadEnabled by viewModel.downloadButtonEnabled.observeAsState(false)
val showProgress by viewModel.isDownloadProgressVisible.observeAsState(false)
val progress by viewModel.downloadProgress.observeAsState(0f)

OfflineAreaSelectorScreen(
downloadEnabled = downloadEnabled,
onDownloadClick = { viewModel.onDownloadClick() },
onCancelClick = { viewModel.onCancelClick() },
onStopDownloadClick = { viewModel.stopDownloading() },
onLocationLockClick = { viewModel.onLocationLockClick() },
locationLockIcon = locationLockIcon,
bottomText = bottomText.toString(),
showProgressDialog = showProgress,
downloadProgress = progress,
mapView = {},
)
}
}
root.addView(
composeView,
android.widget.FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
return root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewLifecycleOwner.lifecycleScope.launch {
viewModel.isDownloadProgressVisible.observe(viewLifecycleOwner) {
showDownloadProgressDialog(it)
}
viewModel.isFailure.observe(viewLifecycleOwner) {
if (it) {
Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG).show()
Expand Down Expand Up @@ -137,22 +162,4 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() {
}

override fun getMapViewModel(): BaseMapViewModel = viewModel

private fun showDownloadProgressDialog(isVisible: Boolean) {
renderComposableDialog {
val openAlertDialog = remember { mutableStateOf(isVisible) }
val progress = viewModel.downloadProgress.observeAsState(0f)
when {
openAlertDialog.value -> {
DownloadProgressDialog(
progress = progress.value,
onDismiss = {
openAlertDialog.value = false
viewModel.stopDownloading()
},
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.android.ui.offlineareas.selector

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.groundplatform.android.R
import org.groundplatform.android.ui.components.MapFloatingActionButton
import org.groundplatform.android.ui.components.MapFloatingActionButtonType

private val TOP_MASK_HEIGHT = 24.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function OfflineAreaSelectorScreen is quite long, as indicated by @Suppress("LongMethod"). To improve readability and maintainability, consider breaking it down into smaller, more focused composable functions. For example, the viewport overlay, the bottom buttons, and the progress dialog could each be their own composable.

fun OfflineAreaSelectorScreen(
downloadEnabled: Boolean,
onDownloadClick: () -> Unit,
onCancelClick: () -> Unit,
onStopDownloadClick: () -> Unit,
onLocationLockClick: () -> Unit,
locationLockIcon: MapFloatingActionButtonType,
bottomText: String,
showProgressDialog: Boolean,
downloadProgress: Float,
mapView: @Composable () -> Unit,
) {
Comment on lines +55 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The onCancelClick parameter is used for both the main cancel button and the download progress dialog's cancel button. This is incorrect, as the dialog's button should stop the download, while the main button navigates up. This is likely why the related test stopDownloading cancels active download and updates UI state is currently ignored.

A separate onStopDownloadClick parameter should be introduced for the dialog's cancel action. This new parameter should then be invoked in the onClick of the AlertDialog's button.

fun OfflineAreaSelectorScreen(
  downloadEnabled: Boolean,
  onDownloadClick: () -> Unit,
  onCancelClick: () -> Unit,
  onStopDownloadClick: () -> Unit,
  onLocationLockClick: () -> Unit,
  locationLockIcon: MapFloatingActionButtonType,
  bottomText: String,
  showProgressDialog: Boolean,
  downloadProgress: Float,
  mapView: @Composable () -> Unit,
)

Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.offline_area_selector_title)) }) }
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// Map (Background)
Box(modifier = Modifier.fillMaxSize()) { mapView() }

// Overlays to create Viewport "Hole"
OfflineAreaSelectorOverlay(
locationLockIcon = locationLockIcon,
onLocationLockClick = onLocationLockClick,
bottomText = bottomText,
onCancelClick = onCancelClick,
onDownloadClick = onDownloadClick,
downloadEnabled = downloadEnabled,
)
}

if (showProgressDialog) {
OfflineAreaProgressDialog(
downloadProgress = downloadProgress,
onStopDownloadClick = onStopDownloadClick,
)
}
}
}

@Composable
private fun OfflineAreaSelectorOverlay(
locationLockIcon: MapFloatingActionButtonType,
onLocationLockClick: () -> Unit,
bottomText: String,
onCancelClick: () -> Unit,
onDownloadClick: () -> Unit,
downloadEnabled: Boolean,
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier.fillMaxWidth().height(TOP_MASK_HEIGHT).background(Color.Black.copy(alpha = 0.4f))
)

Row(modifier = Modifier.weight(1f)) {
// Left Mask
Box(modifier = Modifier.width(24.dp).fillMaxSize().background(Color.Black.copy(alpha = 0.4f)))

// Viewport Hole (Transparent)
Box(
modifier = Modifier.weight(1f).fillMaxSize().border(1.dp, Color.White) // Outline
) {
// Location Lock Button
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)) {
MapFloatingActionButton(type = locationLockIcon, onClick = onLocationLockClick)
}
}

// Right Mask
Box(modifier = Modifier.width(24.dp).fillMaxSize().background(Color.Black.copy(alpha = 0.4f)))
}

// Bottom Mask with Text
Box(
modifier = Modifier.fillMaxWidth().height(80.dp).background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) {
Text(
text = bottomText,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 64.dp),
)
}

OfflineAreaSelectorButtons(
onCancelClick = onCancelClick,
onDownloadClick = onDownloadClick,
downloadEnabled = downloadEnabled,
)
}
}

@Composable
private fun OfflineAreaSelectorButtons(
onCancelClick: () -> Unit,
onDownloadClick: () -> Unit,
downloadEnabled: Boolean,
) {
Row(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) // SurfaceContainer in XML
.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
OutlinedButton(onClick = onCancelClick, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.offline_area_select_cancel_button))
}
Spacer(Modifier.width(16.dp))
Button(onClick = onDownloadClick, enabled = downloadEnabled, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.offline_area_selector_download))
}
}
}

@Composable
private fun OfflineAreaProgressDialog(downloadProgress: Float, onStopDownloadClick: () -> Unit) {
AlertDialog(
onDismissRequest = { /* Prevent dismiss */ },
title = {
Text(
stringResource(
R.string.offline_map_imagery_download_progress_dialog_title,
(downloadProgress * 100).toInt(),
)
)
},
text = {
Column {
Text(stringResource(R.string.offline_map_imagery_download_progress_dialog_message))
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(progress = { downloadProgress }, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Button(onClick = onStopDownloadClick, modifier = Modifier.testTag("CancelProgressButton")) {
Text(stringResource(R.string.offline_area_select_cancel_button))
}
},
)
}
Loading
Loading