From e60338f8ef0308b3a7303be1775f0e9e5fd35091 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 30 Aug 2026 19:19:56 +0530 Subject: [PATCH 1/3] refactor: migrate OfflineAreaSelectorFragment to Jetpack Compose - Replace offline_area_selector_frag.xml with Compose OfflineAreaSelectorScreen - Convert LiveData to StateFlow with OfflineAreaSelectorState and OfflineAreaSelectorEvent - Inline and modernize DownloadProgressDialog inside OfflineAreaSelectorScreen - Remove legacy viewport outline drawable - Add comprehensive Compose UI and ViewModel unit tests --- .../selector/DownloadProgressDialog.kt | 77 ---- .../{model => }/OfflineAreaSelectorEvent.kt | 2 +- .../selector/OfflineAreaSelectorFragment.kt | 109 ++---- .../selector/OfflineAreaSelectorScreen.kt | 345 ++++++++++++++++++ .../{model => }/OfflineAreaSelectorState.kt | 18 +- .../selector/OfflineAreaSelectorViewModel.kt | 121 +++--- .../offline_area_viewport_outline.xml | 25 -- .../res/layout/offline_area_selector_frag.xml | 151 -------- .../selector/DownloadProgressDialogTest.kt | 75 ---- .../OfflineAreaSelectorFragmentTest.kt | 95 ++--- .../selector/OfflineAreaSelectorScreenTest.kt | 294 +++++++++++++++ .../OfflineAreaSelectorViewModelTest.kt | 251 +++++++++++-- 12 files changed, 1024 insertions(+), 539 deletions(-) delete mode 100644 app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialog.kt rename app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/{model => }/OfflineAreaSelectorEvent.kt (93%) create mode 100644 app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorScreen.kt rename app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/{model => }/OfflineAreaSelectorState.kt (75%) delete mode 100644 app/src/main/res/drawable/offline_area_viewport_outline.xml delete mode 100644 app/src/main/res/layout/offline_area_selector_frag.xml delete mode 100644 app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialogTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorScreenTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialog.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialog.kt deleted file mode 100644 index 8170f089fc..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialog.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2024 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.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import org.groundplatform.android.R - -@Composable -fun DownloadProgressDialog(progress: Float, onDismiss: () -> Unit) { - AlertDialog( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - onDismissRequest = {}, - title = { - Text( - stringResource( - R.string.offline_map_imagery_download_progress_dialog_title, - (progress * 100).toInt(), - ), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - text = { - Column { - val animatedProgress by - animateFloatAsState(targetValue = progress, animationSpec = tween(durationMillis = 300)) - - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).testTag("progressBar"), - progress = { animatedProgress }, - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - Text( - stringResource(R.string.offline_map_imagery_download_progress_dialog_message), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { Text(text = stringResource(R.string.cancel)) } - }, - confirmButton = {}, - ) -} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorEvent.kt similarity index 93% rename from app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt rename to app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorEvent.kt index 817c87ba5b..a69d2d483f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorEvent.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.ui.offlineareas.selector.model +package org.groundplatform.android.ui.offlineareas.selector sealed class OfflineAreaSelectorEvent { diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt index 921a9fcfb1..dc61074f9c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt @@ -21,10 +21,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController @@ -32,16 +30,12 @@ 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.databinding.BasemapLayoutBinding 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.components.MapFloatingActionButton import org.groundplatform.android.ui.home.mapcontainer.HomeScreenMapContainerViewModel import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorEvent -import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState -import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.map.MapType import org.groundplatform.ui.map.MapConfig @@ -55,8 +49,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) @@ -69,54 +61,48 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { savedInstanceState: Bundle?, ): View { super.onCreateView(inflater, container, savedInstanceState) - binding = OfflineAreaSelectorFragBinding.inflate(inflater, container, false) + val binding = BasemapLayoutBinding.inflate(inflater, container, false) + binding.composeContent.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setComposableContent { OfflineAreaSelectorScreen(viewModel = viewModel) } + } return binding.root } 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() }, - ) - } - } - binding.downloadButton.setOnClickListener { viewModel.onDownloadClick() } - binding.cancelButton.setOnClickListener { viewModel.onCancelClick() } - setupDownloadProgressDialog() setupObservers() } private fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { viewModel.uiState.collect { updateUi(it) } } - - launch { - viewModel.uiEvent.collect { - when (it) { - is OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen -> { + viewModel.uiEvent.collect { + when (it) { + is OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen -> { + if ( + findNavController().currentDestination?.id == R.id.offline_area_selector_fragment + ) { findNavController() .navigate(OfflineAreaSelectorFragmentDirections.offlineAreaBackToHomescreen()) } + } - is OfflineAreaSelectorEvent.NavigateUp -> { + is OfflineAreaSelectorEvent.NavigateUp -> { + if ( + findNavController().currentDestination?.id == R.id.offline_area_selector_fragment + ) { findNavController().navigateUp() } + } - OfflineAreaSelectorEvent.NetworkUnavailable -> { - popups.ErrorPopup().show(R.string.connect_to_download_message) - } + OfflineAreaSelectorEvent.NetworkUnavailable -> { + popups.ErrorPopup().show(R.string.connect_to_download_message) + } - OfflineAreaSelectorEvent.DownloadError -> { - Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG) - .show() - } + OfflineAreaSelectorEvent.DownloadError -> { + Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG) + .show() } } } @@ -135,53 +121,12 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { override fun onMapReady(map: MapFragment) { // Observe events emitted by the ViewModel. viewLifecycleOwner.lifecycleScope.launch { - mapContainerViewModel.mapLoiFeatures.collect { map.setFeatures(it) } + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + mapContainerViewModel.mapLoiFeatures.collect { map.setFeatures(it) } + } } map.addTileOverlay(viewModel.remoteTileSource) } override fun getMapViewModel(): BaseMapViewModel = viewModel - - private fun updateUi(state: OfflineAreaSelectorState) { - binding.bottomText.text = - when (state.bottomTextState) { - is OfflineAreaSelectorState.BottomTextState.AreaSize -> - resources.getString(R.string.selected_offline_area_size, state.bottomTextState.size) - - OfflineAreaSelectorState.BottomTextState.AreaTooLarge -> - resources.getString(R.string.selected_offline_area_too_large) - - OfflineAreaSelectorState.BottomTextState.Loading -> - resources.getString( - R.string.selected_offline_area_size, - resources.getString(R.string.offline_area_size_loading_symbol), - ) - - OfflineAreaSelectorState.BottomTextState.NetworkError -> - resources.getString(R.string.connect_to_download_message) - - OfflineAreaSelectorState.BottomTextState.NoImageryAvailable -> - resources.getString(R.string.no_imagery_available_for_area) - - null -> "" - } - - with(binding.downloadButton) { - isEnabled = state.isDownloadButtonEnabled() - isClickable = state.isDownloadButtonEnabled() - } - } - - private fun setupDownloadProgressDialog() { - renderComposableDialog { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val downloadState = state.downloadState - if (downloadState is OfflineAreaSelectorState.DownloadState.InProgress) { - DownloadProgressDialog( - progress = downloadState.progress, - onDismiss = { viewModel.stopDownloading() }, - ) - } - } - } } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorScreen.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorScreen.kt new file mode 100644 index 0000000000..fb495907c3 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorScreen.kt @@ -0,0 +1,345 @@ +/* + * 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.annotation.VisibleForTesting +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.fillMaxHeight +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.MapFloatingActionButton +import org.groundplatform.android.ui.components.MapFloatingActionButtonType +import org.groundplatform.android.ui.components.Toolbar +import org.groundplatform.ui.theme.AppTheme + +const val OFFLINE_AREA_SELECTOR_BOTTOM_TEXT_TEST_TAG = "offline_area_selector_bottom_text" +const val OFFLINE_AREA_SELECTOR_DOWNLOAD_BUTTON_TEST_TAG = "offline_area_selector_download_button" +const val OFFLINE_AREA_SELECTOR_CANCEL_BUTTON_TEST_TAG = "offline_area_selector_cancel_button" +const val DOWNLOAD_PROGRESS_DIALOG_CANCEL_BUTTON_TEST_TAG = "download_progress_dialog_cancel_button" + +/** + * Stateful composable for the Offline Area Selector screen. + * + * @param viewModel The view model managing state and actions for offline area selection. + */ +@Composable +fun OfflineAreaSelectorScreen(viewModel: OfflineAreaSelectorViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val locationLockIconType by viewModel.locationLockIconType.collectAsStateWithLifecycle() + + OfflineAreaSelectorScreen( + uiState = uiState, + locationLockIconType = locationLockIconType, + onDownloadClick = { viewModel.onDownloadClick() }, + onCancelClick = { viewModel.onCancelClick() }, + onLocationLockClick = { viewModel.onLocationLockClick() }, + onStopDownloading = { viewModel.stopDownloading() }, + ) +} + +/** + * Stateless composable for the Offline Area Selector screen. + * + * @param uiState Current UI state of the selector screen. + * @param locationLockIconType The current icon type for the location lock button. + * @param onDownloadClick Callback when download button is clicked. + * @param onCancelClick Callback when cancel button is clicked. + * @param onLocationLockClick Callback when location lock button is clicked. + * @param onStopDownloading Callback to cancel and dismiss the active download dialog. + */ +@VisibleForTesting +@Composable +fun OfflineAreaSelectorScreen( + uiState: OfflineAreaSelectorState, + locationLockIconType: MapFloatingActionButtonType, + onDownloadClick: () -> Unit, + onCancelClick: () -> Unit, + onLocationLockClick: () -> Unit, + onStopDownloading: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Toolbar( + stringRes = R.string.offline_area_selector_title, + showNavigationIcon = false, + iconClick = {}, + ) + + OfflineAreaViewportOverlay( + modifier = Modifier.weight(1f).fillMaxWidth(), + bottomTextState = uiState.bottomTextState, + locationLockIconType = locationLockIconType, + onLocationLockClick = onLocationLockClick, + ) + + OfflineAreaSelectorButtons( + isDownloadEnabled = uiState.isDownloadButtonEnabled, + onCancelClick = onCancelClick, + onDownloadClick = onDownloadClick, + ) + } + + val downloadState = uiState.downloadState + if (downloadState is OfflineAreaSelectorState.DownloadState.InProgress) { + DownloadProgressDialog(progress = downloadState.progress, onDismiss = onStopDownloading) + } + } +} + +@Composable +private fun OfflineAreaViewportOverlay( + bottomTextState: OfflineAreaSelectorState.BottomTextState?, + locationLockIconType: MapFloatingActionButtonType, + onLocationLockClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val maskColor = colorResource(R.color.blackOverlay).copy(alpha = 0.4f) + Box(modifier = modifier) { + Column(modifier = Modifier.fillMaxSize()) { + // Top mask + Box(modifier = Modifier.fillMaxWidth().height(24.dp).background(maskColor)) + + // Center row: Left mask, Viewport Outline, Right mask + Row(modifier = Modifier.weight(1f).fillMaxWidth()) { + Box(modifier = Modifier.fillMaxHeight().width(24.dp).background(maskColor)) + + Box( + modifier = + Modifier.weight(1f) + .fillMaxHeight() + .border(3.dp, colorResource(R.color.md_theme_inversePrimary)) + ) { + MapFloatingActionButton( + modifier = Modifier.align(Alignment.BottomEnd), + type = locationLockIconType, + onClick = onLocationLockClick, + ) + } + + Box(modifier = Modifier.fillMaxHeight().width(24.dp).background(maskColor)) + } + + // Bottom mask containing bottom text + Box( + modifier = Modifier.fillMaxWidth().height(80.dp).background(maskColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = getBottomTextMessage(bottomTextState), + color = colorResource(R.color.textOverMap), + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = + Modifier.padding(horizontal = 64.dp) + .testTag(OFFLINE_AREA_SELECTOR_BOTTOM_TEXT_TEST_TAG), + ) + } + } + } +} + +@Composable +private fun OfflineAreaSelectorButtons( + isDownloadEnabled: Boolean, + onCancelClick: () -> Unit, + onDownloadClick: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth().height(90.dp), + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f).testTag(OFFLINE_AREA_SELECTOR_CANCEL_BUTTON_TEST_TAG), + onClick = onCancelClick, + ) { + Text(text = stringResource(R.string.offline_area_select_cancel_button)) + } + + Button( + modifier = Modifier.weight(1f).testTag(OFFLINE_AREA_SELECTOR_DOWNLOAD_BUTTON_TEST_TAG), + onClick = onDownloadClick, + enabled = isDownloadEnabled, + ) { + Text(text = stringResource(R.string.offline_area_selector_download)) + } + } + } +} + +@Composable +private fun getBottomTextMessage(state: OfflineAreaSelectorState.BottomTextState?): String = + when (state) { + is OfflineAreaSelectorState.BottomTextState.AreaSize -> + stringResource(R.string.selected_offline_area_size, state.size) + OfflineAreaSelectorState.BottomTextState.AreaTooLarge -> + stringResource(R.string.selected_offline_area_too_large) + OfflineAreaSelectorState.BottomTextState.Loading -> + stringResource( + R.string.selected_offline_area_size, + stringResource(R.string.offline_area_size_loading_symbol), + ) + OfflineAreaSelectorState.BottomTextState.NetworkError -> + stringResource(R.string.connect_to_download_message) + OfflineAreaSelectorState.BottomTextState.NoImageryAvailable -> + stringResource(R.string.no_imagery_available_for_area) + null -> "" + } + +@Composable +fun DownloadProgressDialog( + progress: Float, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = {}, + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + title = { + Text( + stringResource( + R.string.offline_map_imagery_download_progress_dialog_title, + (progress * 100).toInt(), + ), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Column { + val animatedProgress by + animateFloatAsState(targetValue = progress, animationSpec = tween(durationMillis = 300)) + + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).testTag("progressBar"), + progress = { animatedProgress }, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.offline_map_imagery_download_progress_dialog_message), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + dismissButton = { + TextButton( + modifier = Modifier.testTag(DOWNLOAD_PROGRESS_DIALOG_CANCEL_BUTTON_TEST_TAG), + onClick = { onDismiss() }, + ) { + Text(text = stringResource(R.string.cancel)) + } + }, + confirmButton = {}, + ) +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun OfflineAreaSelectorScreenDefaultPreview() { + AppTheme { + OfflineAreaSelectorScreen( + uiState = OfflineAreaSelectorState(), + locationLockIconType = MapFloatingActionButtonType.LocationNotLocked, + onDownloadClick = {}, + onCancelClick = {}, + onLocationLockClick = {}, + onStopDownloading = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun OfflineAreaSelectorScreenDownloadablePreview() { + AppTheme { + OfflineAreaSelectorScreen( + uiState = + OfflineAreaSelectorState( + bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaSize("5.0") + ), + locationLockIconType = MapFloatingActionButtonType.LocationLocked(), + onDownloadClick = {}, + onCancelClick = {}, + onLocationLockClick = {}, + onStopDownloading = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun OfflineAreaSelectorScreenDownloadingPreview() { + AppTheme { + OfflineAreaSelectorScreen( + uiState = + OfflineAreaSelectorState( + bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaSize("5.0"), + downloadState = OfflineAreaSelectorState.DownloadState.InProgress(0.45f), + ), + locationLockIconType = MapFloatingActionButtonType.LocationLocked(), + onDownloadClick = {}, + onCancelClick = {}, + onLocationLockClick = {}, + onStopDownloading = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorState.kt similarity index 75% rename from app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt rename to app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorState.kt index 2f61758bf7..d151860a49 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorState.kt @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.ui.offlineareas.selector.model +package org.groundplatform.android.ui.offlineareas.selector + +import androidx.compose.runtime.Immutable /** * Represents the complete UI state of the Offline Area Selector screen. @@ -23,20 +25,24 @@ package org.groundplatform.android.ui.offlineareas.selector.model * @property downloadState Represents the current state of the download operation, whether a * download is in progress and its progress. */ +@Immutable data class OfflineAreaSelectorState( val bottomTextState: BottomTextState? = null, val downloadState: DownloadState = DownloadState.Idle, ) { + val isDownloadButtonEnabled: Boolean + get() = bottomTextState is BottomTextState.AreaSize && downloadState is DownloadState.Idle + sealed class BottomTextState { - object Loading : BottomTextState() + data object Loading : BottomTextState() data class AreaSize(val size: String) : BottomTextState() - object NoImageryAvailable : BottomTextState() + data object NoImageryAvailable : BottomTextState() - object AreaTooLarge : BottomTextState() + data object AreaTooLarge : BottomTextState() - object NetworkError : BottomTextState() + data object NetworkError : BottomTextState() } sealed class DownloadState { @@ -44,6 +50,4 @@ data class OfflineAreaSelectorState( data class InProgress(val progress: Float) : DownloadState() } - - fun isDownloadButtonEnabled(): Boolean = bottomTextState is BottomTextState.AreaSize } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt index 2e57a2c914..ced2184d5a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt @@ -15,23 +15,24 @@ */ package org.groundplatform.android.ui.offlineareas.selector +import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.android.system.LocationManager import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.system.SettingsManager import org.groundplatform.android.ui.common.BaseMapViewModel -import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorEvent -import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState import org.groundplatform.domain.model.imagery.RemoteMogTileSource import org.groundplatform.domain.model.imagery.TileSource import org.groundplatform.domain.model.map.Bounds @@ -75,44 +76,58 @@ internal constructor( val remoteTileSource: TileSource = RemoteMogTileSource private var viewport: Bounds? = null + private var updateDownloadSizeJob: Job? = null + + @get:VisibleForTesting + internal var downloadJob: Job? = null + private set private val _uiState = MutableStateFlow(OfflineAreaSelectorState()) val uiState: StateFlow = _uiState - private val _uiEvent = MutableSharedFlow(replay = 0) - val uiEvent = _uiEvent.asSharedFlow() - - var downloadJob: Job? = null + private val uiEventChannel = Channel(Channel.BUFFERED) + val uiEvent: Flow = uiEventChannel.receiveAsFlow() fun onDownloadClick() { if (!networkManager.isNetworkConnected()) { - viewModelScope.launch { _uiEvent.emit(OfflineAreaSelectorEvent.NetworkUnavailable) } + viewModelScope.launch { uiEventChannel.send(OfflineAreaSelectorEvent.NetworkUnavailable) } return } - if (viewport == null) { - // Download was likely clicked before map was ready. + val currentViewport = viewport + if ( + currentViewport == null || + _uiState.value.downloadState is OfflineAreaSelectorState.DownloadState.InProgress + ) { + // Download was likely clicked before map was ready or already in progress. return } - _uiState.value = - _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.InProgress(0f)) + _uiState.update { + it.copy(downloadState = OfflineAreaSelectorState.DownloadState.InProgress(0f)) + } downloadJob = viewModelScope.launch(ioDispatcher) { - offlineAreaRepository - .downloadTiles(viewport!!) - .catch { - _uiState.value = - _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) - _uiEvent.emit(OfflineAreaSelectorEvent.DownloadError) - Timber.d("Download Stopped by $it ") - } - .collect { (bytesDownloaded, totalBytes) -> + try { + var totalDownloaded = 0 + offlineAreaRepository.downloadTiles(currentViewport).collect { + (bytesDownloaded, totalBytes) -> + totalDownloaded = bytesDownloaded updateDownloadProgress(bytesDownloaded, totalBytes) } - _uiState.value = - _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) - _uiEvent.emit(OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen) + _uiState.update { it.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) } + if (totalDownloaded > 0) { + uiEventChannel.send(OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen) + } else { + uiEventChannel.send(OfflineAreaSelectorEvent.DownloadError) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Download failed") + _uiState.update { it.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) } + uiEventChannel.send(OfflineAreaSelectorEvent.DownloadError) + } } } @@ -123,25 +138,25 @@ internal constructor( } else { 0f } - _uiState.value = - _uiState.value.copy( - downloadState = OfflineAreaSelectorState.DownloadState.InProgress(progressValue) - ) + _uiState.update { + it.copy(downloadState = OfflineAreaSelectorState.DownloadState.InProgress(progressValue)) + } } fun onCancelClick() { - viewModelScope.launch { _uiEvent.emit(OfflineAreaSelectorEvent.NavigateUp) } + viewModelScope.launch { uiEventChannel.send(OfflineAreaSelectorEvent.NavigateUp) } } fun stopDownloading() { downloadJob?.cancel() downloadJob = null - _uiState.value = - _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) + _uiState.update { it.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) } } override fun onMapDragged() { - _uiState.value = _uiState.value.copy(bottomTextState = null) + updateDownloadSizeJob?.cancel() + updateDownloadSizeJob = null + _uiState.update { it.copy(bottomTextState = null) } super.onMapDragged() } @@ -150,14 +165,19 @@ internal constructor( val bounds = newCameraPosition.bounds val zoomLevel = newCameraPosition.zoomLevel - if (bounds == null || zoomLevel == null) return - if (zoomLevel < MIN_DOWNLOAD_ZOOM_LEVEL) { - onLargeAreaSelected() + if (bounds == null || zoomLevel == null || zoomLevel < MIN_DOWNLOAD_ZOOM_LEVEL) { + updateDownloadSizeJob?.cancel() + updateDownloadSizeJob = null + viewport = null + if (bounds != null && zoomLevel != null && zoomLevel < MIN_DOWNLOAD_ZOOM_LEVEL) { + onLargeAreaSelected() + } return } viewport = bounds - viewModelScope.launch(ioDispatcher) { updateDownloadSize(bounds) } + updateDownloadSizeJob?.cancel() + updateDownloadSizeJob = viewModelScope.launch(ioDispatcher) { updateDownloadSize(bounds) } } private suspend fun updateDownloadSize(bounds: Bounds) { @@ -172,8 +192,7 @@ internal constructor( onUnavailableAreaSelected() return } - _uiState.value = - _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.Loading) + _uiState.update { it.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.Loading) } offlineAreaRepository .estimateSizeOnDisk(bounds) @@ -192,26 +211,28 @@ internal constructor( } private fun onUpdateDownloadSizeError() { - _uiState.value = - _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.NetworkError) + _uiState.update { + it.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.NetworkError) + } } private fun onUnavailableAreaSelected() { - _uiState.value = - _uiState.value.copy( - bottomTextState = OfflineAreaSelectorState.BottomTextState.NoImageryAvailable - ) + _uiState.update { + it.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.NoImageryAvailable) + } } private fun onDownloadableAreaSelected(sizeInMb: Float) { - _uiState.value = - _uiState.value.copy( + _uiState.update { + it.copy( bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaSize(sizeInMb.toMbString()) ) + } } private fun onLargeAreaSelected() { - _uiState.value = - _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaTooLarge) + _uiState.update { + it.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaTooLarge) + } } } diff --git a/app/src/main/res/drawable/offline_area_viewport_outline.xml b/app/src/main/res/drawable/offline_area_viewport_outline.xml deleted file mode 100644 index 6d34477920..0000000000 --- a/app/src/main/res/drawable/offline_area_viewport_outline.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/offline_area_selector_frag.xml b/app/src/main/res/layout/offline_area_selector_frag.xml deleted file mode 100644 index b57118ee34..0000000000 --- a/app/src/main/res/layout/offline_area_selector_frag.xml +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -