diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreaListItem.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreaListItem.kt deleted file mode 100644 index 1992f20266..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreaListItem.kt +++ /dev/null @@ -1,93 +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 - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.groundplatform.android.R -import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport -import org.groundplatform.ui.theme.AppTheme - -@Composable -fun OfflineAreaListItem( - modifier: Modifier = Modifier, - offlineAreaDetails: OfflineAreaDetails, - itemClicked: (areaId: String) -> Unit = {}, -) { - Column { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 4.dp, end = 24.dp, bottom = 4.dp) - .clickable { itemClicked(offlineAreaDetails.id) }, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_offline_pin), - contentDescription = stringResource(id = R.string.offline_area_list_item_icon), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.Start, - ) { - Text(text = offlineAreaDetails.name, color = MaterialTheme.colorScheme.onSurface) - - Text( - text = - stringResource( - id = R.string.offline_area_list_item_size_on_disk_mb, - offlineAreaDetails.sizeOnDisk, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@Preview(showBackground = true, showSystemUi = true) -@ExcludeFromJacocoGeneratedReport -private fun PreviewOfflineAreaListItem() { - AppTheme { - OfflineAreaListItem( - offlineAreaDetails = - OfflineAreaDetails(id = "id", name = "Region name, Country", sizeOnDisk = "12 MB") - ) - } -} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasFragment.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasFragment.kt index b26688f10d..a920e810d4 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasFragment.kt @@ -19,21 +19,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.groundplatform.android.R -import org.groundplatform.android.databinding.OfflineAreasFragBinding import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.util.setComposableContent @@ -58,36 +52,36 @@ class OfflineAreasFragment : AbstractFragment() { savedInstanceState: Bundle?, ): View { super.onCreateView(inflater, container, savedInstanceState) - val binding = OfflineAreasFragBinding.inflate(inflater, container, false) - binding.viewModel = viewModel - binding.lifecycleOwner = this - binding.offlineAreasListComposeView.setComposableContent { ShowOfflineAreas() } - - getAbstractActivity().setSupportActionBar(binding.offlineAreasToolbar) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewModel.navigateToOfflineAreaSelector.collectLatest { - val navController = findNavController() - if (navController.currentDestination?.id == R.id.offline_areas_fragment) { - navController.navigate(OfflineAreasFragmentDirections.showOfflineAreaSelector()) - } + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setComposableContent { + OfflineAreasScreen( + viewModel = viewModel, + onAreaClick = { areaId -> + val navController = findNavController() + if (navController.currentDestination?.id == R.id.offline_areas_fragment) { + navController.navigate(OfflineAreasFragmentDirections.viewOfflineArea(areaId)) + } + }, + onNavigateUp = { + val navController = findNavController() + if (navController.currentDestination?.id == R.id.offline_areas_fragment) { + navController.navigateUp() + } + }, + ) } } } - @Composable - private fun ShowOfflineAreas() { - val list by viewModel.offlineAreas.observeAsState() - list?.let { - LazyColumn(Modifier.fillMaxSize().testTag("offline area list")) { - items(it) { - OfflineAreaListItem(offlineAreaDetails = it) { areaId -> - findNavController().navigate(OfflineAreasFragmentDirections.viewOfflineArea(areaId)) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigateToOfflineAreaSelector.collect { + val navController = findNavController() + if (navController.currentDestination?.id == R.id.offline_areas_fragment) { + navController.navigate(OfflineAreasFragmentDirections.showOfflineAreaSelector()) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasScreen.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasScreen.kt new file mode 100644 index 0000000000..2674a4a251 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasScreen.kt @@ -0,0 +1,287 @@ +/* + * 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 + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.Toolbar +import org.groundplatform.ui.theme.AppTheme + +const val OFFLINE_AREAS_LIST_TEST_TAG = "offline area list" +const val OFFLINE_AREAS_SELECT_FAB_TEST_TAG = "offline_areas_select_fab" +const val OFFLINE_AREAS_LIST_TITLE_TEST_TAG = "offline_areas_list_title" +const val OFFLINE_AREAS_LIST_TIP_TEST_TAG = "offline_areas_list_tip" +const val OFFLINE_AREAS_NO_AREAS_MESSAGE_TEST_TAG = "offline_areas_no_areas_message" +const val OFFLINE_AREAS_LOADING_SPINNER_TEST_TAG = "offline_areas_loading_spinner" + +/** + * Stateful entry point for the Offline Areas list screen. + * + * @param viewModel The ViewModel providing UI state and actions. + * @param onAreaClick Callback when an offline area item in the list is clicked. + * @param onNavigateUp Callback when the back navigation icon is clicked. + */ +@Composable +fun OfflineAreasScreen( + viewModel: OfflineAreasViewModel, + onAreaClick: (String) -> Unit, + onNavigateUp: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + OfflineAreasScreen( + uiState = uiState, + onAreaClick = onAreaClick, + onSelectAreaClick = { viewModel.showOfflineAreaSelector() }, + onNavigateUp = onNavigateUp, + ) +} + +/** + * Stateless composable for the Offline Areas list screen. + * + * @param uiState Current UI state of the offline areas list screen. + * @param onAreaClick Callback when an offline area item in the list is clicked. + * @param onSelectAreaClick Callback when the select area FAB is clicked. + * @param onNavigateUp Callback when the back navigation icon is clicked. + * @param modifier Modifier for the root container. + */ +@VisibleForTesting +@Composable +fun OfflineAreasScreen( + uiState: OfflineAreasState, + onAreaClick: (String) -> Unit, + onSelectAreaClick: () -> Unit, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Toolbar( + stringRes = R.string.offline_map_imagery, + showNavigationIcon = true, + iconClick = onNavigateUp, + ) + }, + floatingActionButton = { OfflineAreasSelectFab(onSelectAreaClick) }, + floatingActionButtonPosition = FabPosition.Center, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + when { + uiState.isLoading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.testTag(OFFLINE_AREAS_LOADING_SPINNER_TEST_TAG) + ) + } + } + + uiState.isEmpty -> { + OfflineAreasEmptyView() + } + + else -> { + OfflineAreasLoadedView(uiState.offlineAreas, onAreaClick) + } + } + } + } +} + +@Composable +private fun OfflineAreasSelectFab(onClick: () -> Unit) { + ExtendedFloatingActionButton( + onClick = onClick, + icon = { + Icon( + painter = painterResource(R.drawable.ic_maps_ar), + contentDescription = stringResource(R.string.offline_area_selector_prompt), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + text = { Text(text = stringResource(R.string.offline_area_selector_select)) }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.testTag(OFFLINE_AREAS_SELECT_FAB_TEST_TAG), + ) +} + +@Composable +private fun OfflineAreasEmptyView() { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.offline_map_imagery), + contentDescription = stringResource(R.string.offline_map_imagery_no_areas_downloaded_image), + modifier = Modifier.size(200.dp), + ) + Text( + text = stringResource(R.string.no_basemaps_downloaded), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp).testTag(OFFLINE_AREAS_NO_AREAS_MESSAGE_TEST_TAG), + ) + } +} + +@Composable +private fun OfflineAreasLoadedView( + areas: List, + onAreaClick: (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 24.dp, bottom = 8.dp) + ) { + Text( + text = stringResource(R.string.offline_downloaded_areas), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag(OFFLINE_AREAS_LIST_TITLE_TEST_TAG), + ) + Text( + text = stringResource(R.string.offline_area_list_tip), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(OFFLINE_AREAS_LIST_TIP_TEST_TAG), + ) + } + + LazyColumn(modifier = Modifier.fillMaxSize().testTag(OFFLINE_AREAS_LIST_TEST_TAG)) { + items(items = areas, key = { it.id }) { area -> + OfflineAreaListItem(offlineAreaDetails = area, itemClicked = onAreaClick) + } + } + } +} + +@Composable +private fun OfflineAreaListItem( + offlineAreaDetails: OfflineAreaDetails, + modifier: Modifier = Modifier, + itemClicked: (areaId: String) -> Unit = {}, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 4.dp, end = 24.dp, bottom = 4.dp) + .clickable { itemClicked(offlineAreaDetails.id) }, + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_offline_pin), + contentDescription = stringResource(id = R.string.offline_area_list_item_icon), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.Start, + ) { + Text(text = offlineAreaDetails.name, color = MaterialTheme.colorScheme.onSurface) + + Text( + text = + stringResource( + id = R.string.offline_area_list_item_size_on_disk_mb, + offlineAreaDetails.sizeOnDisk, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun OfflineAreasScreenEmptyPreview() { + AppTheme { + OfflineAreasScreen( + uiState = OfflineAreasState(offlineAreas = emptyList(), isLoading = false), + onAreaClick = {}, + onSelectAreaClick = {}, + onNavigateUp = {}, + ) + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun OfflineAreasScreenLoadedPreview() { + AppTheme { + OfflineAreasScreen( + uiState = + OfflineAreasState( + offlineAreas = + listOf( + OfflineAreaDetails("id_1", "Downtown", "1.2 MB"), + OfflineAreaDetails("id_2", "Forest Reserve", "4.5 MB"), + ), + isLoading = false, + ), + onAreaClick = {}, + onSelectAreaClick = {}, + onNavigateUp = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasState.kt new file mode 100644 index 0000000000..9ee1332f43 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasState.kt @@ -0,0 +1,29 @@ +/* + * 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 + +import androidx.compose.runtime.Immutable + +/** Represents the UI state for the Offline Areas list screen. */ +@Immutable +data class OfflineAreasState( + val offlineAreas: List = emptyList(), + val isLoading: Boolean = true, +) { + val isEmpty: Boolean + get() = !isLoading && offlineAreas.isEmpty() +} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasViewModel.kt index 4f161d244c..40a9c6016c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/OfflineAreasViewModel.kt @@ -15,14 +15,15 @@ */ package org.groundplatform.android.ui.offlineareas -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.domain.model.imagery.OfflineArea @@ -38,28 +39,28 @@ class OfflineAreasViewModel internal constructor(private val offlineAreaRepository: OfflineAreaRepositoryInterface) : AbstractViewModel() { - /** - * Returns the current list of downloaded offline map imagery areas available for viewing. If an - * unexpected error accessing the local store is encountered, emits an empty list, circumventing - * the error. - */ - val offlineAreas: LiveData> + val uiState: StateFlow = + offlineAreaRepository + .offlineAreas() + .map { list -> + OfflineAreasState( + offlineAreas = list.map { toOfflineAreaDetails(it) }, + isLoading = false, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = OfflineAreasState(isLoading = true), + ) - val showList: LiveData - val showNoAreasMessage: LiveData - val showProgressSpinner: LiveData + private val navigateToOfflineAreaSelectorChannel = Channel(Channel.BUFFERED) + val navigateToOfflineAreaSelector: Flow = + navigateToOfflineAreaSelectorChannel.receiveAsFlow() - private val _navigateToOfflineAreaSelector = - MutableSharedFlow(extraBufferCapacity = 1, replay = 0) - val navigateToOfflineAreaSelector = _navigateToOfflineAreaSelector.asSharedFlow() - - init { - val offlineAreas = - offlineAreaRepository.offlineAreas().map { list -> list.map { toOfflineAreaDetails(it) } } - this.offlineAreas = offlineAreas.asLiveData() - showProgressSpinner = offlineAreas.map { false }.onStart { emit(true) }.asLiveData() - showNoAreasMessage = offlineAreas.map { it.isEmpty() }.onStart { emit(false) }.asLiveData() - showList = offlineAreas.map { it.isNotEmpty() }.onStart { emit(false) }.asLiveData() + /** Navigate to the area selector for offline map imagery. */ + fun showOfflineAreaSelector() { + viewModelScope.launch { navigateToOfflineAreaSelectorChannel.send(Unit) } } private fun toOfflineAreaDetails(offlineArea: OfflineArea) = @@ -67,9 +68,4 @@ internal constructor(private val offlineAreaRepository: OfflineAreaRepositoryInt private fun OfflineArea.getSizeOnDevice() = offlineAreaRepository.sizeOnDevice(this).toMb().toMbString() - - /** Navigate to the area selector for offline map imagery. */ - fun showOfflineAreaSelector() { - viewModelScope.launch { _navigateToOfflineAreaSelector.emit(Unit) } - } } 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/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt index c5c2790224..c97250c5ab 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt @@ -19,17 +19,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch -import org.groundplatform.android.databinding.OfflineAreaViewerFragBinding +import org.groundplatform.android.R +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.map.MapFragment +import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.map.MapType import org.groundplatform.ui.map.MapConfig @@ -41,7 +46,6 @@ class OfflineAreaViewerFragment @Inject constructor() : AbstractMapContainerFrag override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val args = OfflineAreaViewerFragmentArgs.fromBundle(requireArguments()) viewModel = getViewModel(OfflineAreaViewerViewModel::class.java) viewModel.initialize(args.offlineAreaId) @@ -53,9 +57,12 @@ class OfflineAreaViewerFragment @Inject constructor() : AbstractMapContainerFrag override fun onMapReady(map: MapFragment) { super.onMapReady(map) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.area.observe(this@OfflineAreaViewerFragment) { map.viewport = it.bounds } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState + .mapNotNull { it.area } + .distinctUntilChanged() + .collect { map.viewport = it.bounds } } } } @@ -66,16 +73,30 @@ class OfflineAreaViewerFragment @Inject constructor() : AbstractMapContainerFrag savedInstanceState: Bundle?, ): View { super.onCreateView(inflater, container, savedInstanceState) - val binding = OfflineAreaViewerFragBinding.inflate(inflater, container, false) - binding.viewModel = viewModel - binding.lifecycleOwner = this - getAbstractActivity().setSupportActionBar(binding.offlineAreaViewerToolbar) + val binding = BasemapLayoutBinding.inflate(inflater, container, false) + binding.composeContent.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setComposableContent { + OfflineAreaViewerScreen( + viewModel = viewModel, + onNavigateUp = { findNavController().navigateUp() }, + ) + } + } return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { viewModel.navigateUp.collect { findNavController().navigateUp() } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigateUp.collect { + if (findNavController().currentDestination?.id == R.id.offline_area_viewer_fragment) { + findNavController().navigateUp() + } + } + } + } } override fun getMapViewModel(): BaseMapViewModel = viewModel diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerScreen.kt new file mode 100644 index 0000000000..cdac0c7417 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.viewer + +import androidx.activity.compose.BackHandler +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.Toolbar +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.imagery.OfflineArea +import org.groundplatform.domain.model.map.Bounds +import org.groundplatform.ui.theme.AppTheme + +const val OFFLINE_AREA_VIEWER_NAME_TEST_TAG = "offline_area_viewer_name" +const val OFFLINE_AREA_VIEWER_SIZE_TEST_TAG = "offline_area_viewer_size" +const val OFFLINE_AREA_VIEWER_REMOVE_BUTTON_TEST_TAG = "offline_area_viewer_remove_button" +const val OFFLINE_AREA_VIEWER_PROGRESS_OVERLAY_TEST_TAG = "offline_area_viewer_progress_overlay" + +@Composable +fun OfflineAreaViewerScreen(viewModel: OfflineAreaViewerViewModel, onNavigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + OfflineAreaViewerScreen( + uiState = uiState, + onRemoveClick = { viewModel.onRemoveButtonClick() }, + onNavigateUp = onNavigateUp, + ) +} + +@VisibleForTesting +@Composable +fun OfflineAreaViewerScreen( + uiState: OfflineAreaViewerState, + onRemoveClick: () -> Unit, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = uiState.isProgressOverlayVisible) {} + + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Toolbar( + stringRes = R.string.offline_area_viewer_title, + showNavigationIcon = true, + iconClick = { if (!uiState.isProgressOverlayVisible) onNavigateUp() }, + ) + + Spacer(modifier = Modifier.weight(1f)) + + OfflineAreaDetailsCard( + areaName = uiState.areaName, + areaSize = uiState.areaSize, + isRemoveButtonEnabled = uiState.isRemoveButtonEnabled, + onRemoveClick = onRemoveClick, + ) + } + + if (uiState.isProgressOverlayVisible) { + Box( + modifier = + Modifier.fillMaxSize() + .background(colorResource(R.color.blackOverlay).copy(alpha = 0.5f)) + .clickable( + enabled = true, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + ) + .testTag(OFFLINE_AREA_VIEWER_PROGRESS_OVERLAY_TEST_TAG), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun OfflineAreaDetailsCard( + areaName: String, + areaSize: String?, + isRemoveButtonEnabled: Boolean, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(top = 40.dp, bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = areaName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(OFFLINE_AREA_VIEWER_NAME_TEST_TAG), + ) + + if (areaSize != null) { + Text( + text = stringResource(R.string.offline_area_size_on_disk_mb, areaSize), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(OFFLINE_AREA_VIEWER_SIZE_TEST_TAG), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRemoveClick, + enabled = isRemoveButtonEnabled, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.testTag(OFFLINE_AREA_VIEWER_REMOVE_BUTTON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.offline_area_viewer_remove_button)) + } + } + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun OfflineAreaViewerScreenPreview() { + AppTheme { + OfflineAreaViewerScreen( + uiState = + OfflineAreaViewerState( + area = + OfflineArea( + id = "id_1", + name = "Test Area", + bounds = Bounds(Coordinates(0.0, 0.0), Coordinates(1.0, 1.0)), + state = OfflineArea.State.DOWNLOADED, + zoomRange = 0..10, + ), + areaName = "Test Area", + areaSize = "1.5", + isProgressOverlayVisible = false, + ), + onRemoveClick = {}, + onNavigateUp = {}, + ) + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun OfflineAreaViewerScreenLoadingPreview() { + AppTheme { + OfflineAreaViewerScreen( + uiState = OfflineAreaViewerState(isProgressOverlayVisible = true), + onRemoveClick = {}, + onNavigateUp = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerState.kt new file mode 100644 index 0000000000..c3b407487a --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerState.kt @@ -0,0 +1,32 @@ +/* + * 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.viewer + +import androidx.compose.runtime.Immutable +import org.groundplatform.domain.model.imagery.OfflineArea + +/** Represents the UI state for the Offline Area Viewer screen. */ +@Immutable +data class OfflineAreaViewerState( + val area: OfflineArea? = null, + val areaName: String = "", + val areaSize: String? = null, + val isProgressOverlayVisible: Boolean = false, +) { + val isRemoveButtonEnabled: Boolean + get() = area != null && !isProgressOverlayVisible +} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt index 0b6bac3d47..3eb4579caa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt @@ -15,12 +15,15 @@ */ package org.groundplatform.android.ui.offlineareas.viewer -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +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 @@ -37,7 +40,7 @@ import org.groundplatform.ui.util.toMbString import timber.log.Timber /** - * View model for the OfflineAreaViewerFragment. Manges offline area deletions and calculates the + * View model for the OfflineAreaViewerFragment. Manages offline area deletions and calculates the * storage size of an area on the user's device. */ class OfflineAreaViewerViewModel @@ -62,37 +65,51 @@ constructor( locationOfInterestRepository, ) { - /** Returns the offline area associated with this view model. */ - val area = MutableLiveData() - val areaName = MutableLiveData() - val areaSize = MutableLiveData() - val progressOverlayVisible = MutableLiveData() + private val _uiState = MutableStateFlow(OfflineAreaViewerState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _navigateUp = MutableSharedFlow(replay = 0) - val navigateUp = _navigateUp.asSharedFlow() + private val navigateUpChannel = Channel(Channel.BUFFERED) + val navigateUp = navigateUpChannel.receiveAsFlow() /** Initialize the view model with the given arguments. */ fun initialize(offlineAreaId: String) { viewModelScope.launch(ioDispatcher) { val thisArea = offlineAreaRepository.getOfflineArea(offlineAreaId) thisArea?.let { - area.postValue(it) - areaSize.postValue(offlineAreaRepository.sizeOnDevice(it).toMb().toMbString()) - areaName.postValue(it.name) - } ?: run { _navigateUp.emit(Unit) } + val size = offlineAreaRepository.sizeOnDevice(it).toMb().toMbString() + _uiState.update { state -> + state.copy( + area = it, + areaName = it.name, + areaSize = size, + ) + } + } ?: run { navigateUpChannel.send(Unit) } } } /** Deletes the area associated with this view model. */ fun onRemoveButtonClick() { - progressOverlayVisible.value = true - viewModelScope.launch(ioDispatcher) { removeOfflineArea(area.value) } + if (_uiState.value.isProgressOverlayVisible || _uiState.value.area == null) return + _uiState.update { it.copy(isProgressOverlayVisible = true) } + viewModelScope.launch(ioDispatcher) { removeOfflineArea(_uiState.value.area) } } private suspend fun removeOfflineArea(deletedArea: OfflineArea?) { - if (deletedArea == null) return - Timber.d("Removing offline area ${deletedArea.name}") - offlineAreaRepository.removeFromDevice(deletedArea) - _navigateUp.emit(Unit) + if (deletedArea == null) { + _uiState.update { it.copy(isProgressOverlayVisible = false) } + return + } + try { + Timber.d("Removing offline area ${deletedArea.name}") + offlineAreaRepository.removeFromDevice(deletedArea) + _uiState.update { it.copy(isProgressOverlayVisible = false, area = null) } + navigateUpChannel.send(Unit) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to remove offline area") + _uiState.update { it.copy(isProgressOverlayVisible = false) } + } } } 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -