diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt index 82c9af942a..00aba755b3 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt @@ -41,6 +41,7 @@ import org.groundplatform.android.ui.common.BackPressListener import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.home.HomeScreenFragmentDirections import org.groundplatform.android.util.renderComposableDialog +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.task.Task /** Fragment allowing the user to collect data to complete a task. */ @@ -68,14 +69,19 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { guideline = binding.progressBarGuideline getAbstractActivity().setSupportActionBar(binding.dataCollectionToolbar) - binding.dataCollectionToolbar.setNavigationOnClickListener { showExitWarningDialog() } + binding.dataCollectionToolbar.setNavigationOnClickListener { + if (viewModel.uiState.value is DataCollectionUiState.TaskSubmitted) { + navigateBack() + } else { + showExitWarningDialog() + } + } return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner viewPager.isUserInputEnabled = false viewPager.offscreenPageLimit = 1 @@ -110,8 +116,8 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { when (uiState) { // Ensure adapter has the task list; then jump to the current position. is DataCollectionUiState.Ready -> { - binding.jobName = uiState.job.name - binding.loiName = uiState.loiName + binding.dataCollectionToolbar.title = uiState.job.name + binding.dataCollectionToolbar.subtitle = uiState.loiName loadTasks(uiState.tasks, uiState.position) } @@ -120,7 +126,9 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { } is DataCollectionUiState.TaskSubmitted -> { - onTaskSubmitted() + binding.dataCollectionToolbar.title = getString(R.string.data_collection_complete) + binding.dataCollectionToolbar.subtitle = null + onTaskSubmitted(uiState.loiReport) } is DataCollectionUiState.Loading, @@ -156,14 +164,12 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { updateProgressBar(taskPosition, true) } - private fun onTaskSubmitted() { - // Hide close button - binding.dataCollectionToolbar.navigationIcon = null + private fun onTaskSubmitted(loiReport: LoiReport?) { viewPager.adapter = null // Display a confirmation dialog and move to home screen after that. renderComposableDialog { - DataSubmissionConfirmationScreen { + DataSubmissionConfirmationScreen(loiReport) { findNavController().navigate(HomeScreenFragmentDirections.showHomeScreen()) } } @@ -189,7 +195,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { } override fun onBack(): Boolean { - if (viewModel.uiState.value == DataCollectionUiState.TaskSubmitted) { + if (viewModel.uiState.value is DataCollectionUiState.TaskSubmitted) { // Pressing back button after submitting task should navigate back to home screen. navigateBack() return true diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionUiState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionUiState.kt index ef2e3a73b2..ff9aa9b81b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionUiState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionUiState.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.ui.datacollection import org.groundplatform.domain.model.job.Job +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.task.Task /** @@ -85,7 +86,7 @@ sealed interface DataCollectionUiState { * affordance or navigate away (e.g., back to the home screen) and must not attempt to read or * save further draft data for this session. */ - data object TaskSubmitted : DataCollectionUiState + data class TaskSubmitted(val loiReport: LoiReport?) : DataCollectionUiState } /** Stable, UI-mappable error codes for data collection bootstrapping and flow. */ diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index 198c81eba7..4c615d9417 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -56,6 +56,7 @@ import org.groundplatform.domain.model.submission.TaskData import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.model.submission.isNotNullOrEmpty import org.groundplatform.domain.model.task.Task +import org.groundplatform.domain.usecases.GetLoiReportUseCase import timber.log.Timber /** View model for the Data Collection fragment. */ @@ -72,6 +73,7 @@ internal constructor( private val popups: Provider, private val viewModelFactory: ViewModelFactory, private val dataCollectionInitializer: DataCollectionInitializer, + private val getLoiReportUseCase: GetLoiReportUseCase, ) : AbstractViewModel() { /** The current vertical position of the task view footer. */ @@ -86,7 +88,6 @@ internal constructor( private val jobId: String = requireNotNull(savedStateHandle[TASK_JOB_ID_KEY]) private val loiId: String? = savedStateHandle[TASK_LOI_ID_KEY] - private val loiName: String? = savedStateHandle[TASK_LOI_NAME_KEY] private val taskDataHandler = TaskDataHandler() private lateinit var taskSequenceHandler: TaskSequenceHandler @@ -99,7 +100,13 @@ internal constructor( init { viewModelScope.launch { - val initResult = dataCollectionInitializer.initialize(savedStateHandle, jobId, loiId, loiName) + val initResult = + dataCollectionInitializer.initialize( + savedStateHandle, + jobId, + loiId, + getTypedLoiNameOrEmpty(), + ) if (initResult is DataCollectionUiState.Ready) { taskSequenceHandler = TaskSequenceHandler(initResult.tasks, taskDataHandler) @@ -167,8 +174,16 @@ internal constructor( moveToNextTask() } else { clearDraft() - saveChanges(st, getDeltas()) - _uiState.value = DataCollectionUiState.TaskSubmitted + externalScope.launch(ioDispatcher) { + val submittedLoiId = saveChanges(st, getDeltas()) + val loiReport = + getLoiReportUseCase.invoke( + loiName = getTypedLoiNameOrEmpty(), + loiId = submittedLoiId, + surveyId = st.surveyId, + ) + _uiState.value = DataCollectionUiState.TaskSubmitted(loiReport) + } } } } @@ -240,18 +255,19 @@ internal constructor( moveToTask(withReady { taskSequenceHandler.getNextTask(it.currentTaskId) }) } - private fun saveChanges(state: DataCollectionUiState.Ready, deltas: List) { - externalScope.launch(ioDispatcher) { - val collectionId = offlineUuidGenerator.generateUuid() - submitDataUseCase.invoke( - loiId, - state.job, - state.surveyId, - deltas, - savedStateHandle[TASK_LOI_NAME_KEY], - collectionId, - ) - } + private suspend fun saveChanges( + state: DataCollectionUiState.Ready, + deltas: List, + ): String { + val collectionId = offlineUuidGenerator.generateUuid() + return submitDataUseCase.invoke( + selectedLoiId = loiId, + job = state.job, + surveyId = state.surveyId, + deltas = deltas, + loiName = savedStateHandle[TASK_LOI_NAME_KEY], + collectionId = collectionId, + ) } private fun suppressDrafts() { @@ -282,7 +298,7 @@ internal constructor( val state = uiState.value if ( - state == DataCollectionUiState.TaskSubmitted || + state is DataCollectionUiState.TaskSubmitted || deltas.isEmpty() || state !is DataCollectionUiState.Ready ) { diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt index 8140ae3786..425d1b912f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt @@ -16,92 +16,182 @@ package org.groundplatform.android.ui.datacollection import android.content.res.Configuration -import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.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.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +private val DEFAULT_TOOLBAR_HEIGHT = 56.dp + @Composable -fun DataSubmissionConfirmationScreen(onDismissed: () -> Unit) { +fun DataSubmissionConfirmationScreen(loiReport: LoiReport? = null, onDismissed: () -> Unit) { + val baseModifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .padding(top = DEFAULT_TOOLBAR_HEIGHT) + .systemBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 48.dp) if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { Row( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + modifier = baseModifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - DataSubmittedImage() - BodyContent { onDismissed() } + Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + HeaderContent() + OutlinedButton(modifier = Modifier.padding(top = 24.dp), onClick = { onDismissed() }) { + Text( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource(id = R.string.close), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + ShareableContent(modifier = Modifier.weight(1f), loiReport = loiReport) } } else { - Column( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - DataSubmittedImage() - BodyContent { onDismissed() } + Column(modifier = baseModifier, horizontalAlignment = Alignment.CenterHorizontally) { + HeaderContent(modifier = Modifier.padding(vertical = 16.dp)) + ShareableContent(loiReport = loiReport) + OutlinedButton(modifier = Modifier.padding(vertical = 24.dp), onClick = { onDismissed() }) { + Text( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource(id = R.string.close), + style = MaterialTheme.typography.bodyMedium, + ) + } } } } @Composable -private fun DataSubmittedImage() { - Image( - painter = painterResource(id = R.drawable.data_submitted), - contentDescription = stringResource(R.string.data_submitted_image), - contentScale = ContentScale.Fit, - ) +private fun DataSubmittedIcon(modifier: Modifier = Modifier) { + Box( + modifier = + modifier.size(64.dp).clip(CircleShape).background(MaterialTheme.colorScheme.secondary) + ) { + Icon( + modifier = Modifier.align(Alignment.Center).fillMaxSize().padding(8.dp), + painter = painterResource(id = R.drawable.baseline_check_24), + contentDescription = null, + tint = Color.White, + ) + } } @Composable -private fun BodyContent(onDismiss: () -> Unit) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.data_collection_complete), - style = MaterialTheme.typography.titleLarge, - ) +private fun HeaderContent(modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + DataSubmittedIcon(modifier = Modifier.padding(8.dp)) Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier.padding(horizontal = 28.dp), text = stringResource(R.string.data_collection_complete_details), style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(30.dp)) - OutlinedButton(onClick = { onDismiss() }) { + } +} + +@Composable +private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport?) { + loiReport?.let { + Column(modifier) { Text( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 10.dp), - text = stringResource(id = R.string.close), - style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + text = stringResource(R.string.share_location), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, ) + Box( + modifier = + Modifier.fillMaxWidth() + .background( + MaterialTheme.colorScheme.background, + RoundedCornerShape(12.dp), + ) // todo consider shapes + .padding(24.dp) + ) { + GroundQrCode( + modifier = Modifier.align(Alignment.Center), + title = loiReport.loiName, + footer = stringResource(R.string.scan_this_qr_to_download_geojson), + content = loiReport.geoJson.toString(), + contentDescription = "QR code with LOI Geometry", + centerLogoPainter = painterResource(R.drawable.ground_logo), + ) + } } } } +private val testLoiReport = + LoiReport( + loiName = "Test LOI", + geoJson = + JsonObject( + mapOf( + "type" to JsonPrimitive("Feature"), + "properties" to JsonObject(mapOf("name" to JsonPrimitive("Point test"))), + "geometry" to + JsonObject( + mapOf( + "type" to JsonPrimitive("Point"), + "coordinates" to JsonArray(listOf(JsonPrimitive(-89.0), JsonPrimitive(41.0))), + ) + ), + ) + ), + ) + +@Composable +@Preview(showSystemUi = true) +@ExcludeFromJacocoGeneratedReport +private fun DataSubmissionConfirmationScreenPortraitPreview() { + AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } +} + @Composable @Preview(heightDp = 320, widthDp = 800) @ExcludeFromJacocoGeneratedReport -private fun DataSubmissionConfirmationScreenPreview() { - AppTheme { DataSubmissionConfirmationScreen {} } +private fun DataSubmissionConfirmationScreenLandscapePreview() { + AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index 3cd1d83912..e7618b1d1d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -47,6 +47,7 @@ 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.common.LocationOfInterestHelper import org.groundplatform.android.ui.common.SharedViewModel import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState @@ -60,6 +61,7 @@ import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.domain.usecases.GetLoiReportUseCase @OptIn(ExperimentalCoroutinesApi::class) @SharedViewModel @@ -77,6 +79,8 @@ internal constructor( private val surveyRepository: SurveyRepository, private val userRepository: UserRepositoryInterface, private val localValueStore: LocalValueStore, + private val locationOfInterestHelper: LocationOfInterestHelper, + private val getLoiReportUseCase: GetLoiReportUseCase, ) : BaseMapViewModel( locationManager, @@ -212,11 +216,19 @@ internal constructor( .firstOrNull { it.geometry == feature?.geometry } ?.let { loi -> val canDelete = userRepository.canDeleteLoi(loi) + val loiReport = + getLoiReportUseCase.invoke( + loiName = locationOfInterestHelper.getDisplayLoiName(loi), + loiId = loi.id, + surveyId = activeSurvey.filterNotNull().first().id, + ) + SelectedLoiSheetData( canCollectData = canUserSubmitData, loi = loi, submissionCount = submissionRepository.getTotalSubmissionCount(loi), showDeleteLoiButton = canDelete, + loiReport = loiReport, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/DataCollectionEntryPointData.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/DataCollectionEntryPointData.kt index c3cb9cc697..8a9b289a31 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/DataCollectionEntryPointData.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/DataCollectionEntryPointData.kt @@ -18,6 +18,7 @@ package org.groundplatform.android.ui.home.mapcontainer.jobs import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.locationofinterest.LoiReport /** Data classes used to populate the data collection entry UI, like the LOI bottom sheet. */ sealed interface DataCollectionEntryPointData { @@ -29,6 +30,7 @@ data class SelectedLoiSheetData( val loi: LocationOfInterest, val submissionCount: Int, val showDeleteLoiButton: Boolean, + val loiReport: LoiReport?, ) : DataCollectionEntryPointData data class AdHocDataCollectionButtonData(override val canCollectData: Boolean, val job: Job) : diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt index 4d613fa339..5a73e513f6 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt @@ -26,6 +26,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -44,15 +48,19 @@ import org.groundplatform.ui.theme.AppTheme fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit) { when (state) { is JobMapComponentState.LoiSelected -> { + var showShareLoiModal by rememberSaveable { mutableStateOf(false) } + LoiJobSheet( - loi = state.loi.loi, + state = state.loi, onCollectClicked = { onAction(OnAddDataClicked(state.loi)) }, onDeleteClicked = { onAction(OnDeleteSiteClicked(state.loi)) }, onDismiss = { onAction(JobMapComponentAction.OnJobCardDismissed) }, - canUserSubmitData = state.loi.canCollectData, - submissionCount = state.loi.submissionCount, - showDeleteLoiButton = state.loi.showDeleteLoiButton, + onShareClicked = { showShareLoiModal = true }, ) + + if (showShareLoiModal && state.loi.loiReport != null) { + ShareLocationModal(state.loi.loiReport) { showShareLoiModal = false } + } } is JobMapComponentState.AddLoiButton -> { AddLoiButton(onClick = { onAction(JobMapComponentAction.OnAddLoiButtonClicked) }) diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt index 3d6d186952..b02a1816cf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt @@ -23,9 +23,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -64,16 +67,14 @@ import org.groundplatform.ui.theme.AppTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoiJobSheet( - loi: LocationOfInterest, - canUserSubmitData: Boolean, - submissionCount: Int, - showDeleteLoiButton: Boolean = false, + state: SelectedLoiSheetData, onCollectClicked: () -> Unit, onDeleteClicked: (() -> Unit)? = null, onDismiss: () -> Unit, + onShareClicked: () -> Unit, ) { val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = onDismiss, @@ -81,20 +82,31 @@ fun LoiJobSheet( containerColor = MaterialTheme.colorScheme.surface, dragHandle = { BottomSheetDefaults.DragHandle(width = 32.dp) }, ) { - ModalContents(loi, canUserSubmitData, submissionCount, showDeleteLoiButton, onDeleteClicked) { - scope.launch { sheetState.hide() }.invokeOnCompletion { onCollectClicked() } - } + ModalContents( + loi = state.loi, + canUserSubmitData = state.canCollectData, + submissionCount = state.submissionCount, + showDeleteLoiButton = state.showDeleteLoiButton, + showShareButton = state.loiReport != null, + onDeleteClicked = onDeleteClicked, + onCollectClicked = { + scope.launch { sheetState.hide() }.invokeOnCompletion { onCollectClicked() } + }, + onShareClicked = onShareClicked, + ) } } @Composable -private fun ModalContents( +private fun ModalContents( // todo consider refactor to simpler state loi: LocationOfInterest, canUserSubmitData: Boolean, submissionCount: Int, showDeleteLoiButton: Boolean, + showShareButton: Boolean, onDeleteClicked: (() -> Unit)?, onCollectClicked: () -> Unit, + onShareClicked: () -> Unit, ) { val resources = LocalContext.current.resources val loiHelper = remember(resources) { LocationOfInterestHelper(resources) } @@ -107,7 +119,9 @@ private fun ModalContents( loi = loi, submissionCount = submissionCount, canUserSubmitData = canUserSubmitData, + showShareButton = showShareButton, onCollectClicked = onCollectClicked, + onShareClicked = onShareClicked, ) DeleteSiteSection( showDeleteLoiButton = showDeleteLoiButton, @@ -163,13 +177,11 @@ private fun SubmissionRow( loi: LocationOfInterest, submissionCount: Int, canUserSubmitData: Boolean, + showShareButton: Boolean, onCollectClicked: () -> Unit, + onShareClicked: () -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Top) { Text( if (submissionCount <= 0) stringResource(R.string.no_submissions) else pluralStringResource(R.plurals.submission_count, submissionCount, submissionCount), @@ -177,11 +189,27 @@ private fun SubmissionRow( style = MaterialTheme.typography.bodyLarge, ) - // NOTE(#2539): Avoid crash when there are no non-LOI tasks. - val showAddData = canUserSubmitData && loi.job.hasNonLoiTasks() && loi.isPredefined == true - if (showAddData) { - Button(onClick = onCollectClicked) { - Text(stringResource(R.string.add_data), modifier = Modifier.padding(4.dp)) + Row( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (showShareButton) { + FilledTonalButton(onClick = onShareClicked) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Outlined.Share, + contentDescription = "Share", + ) + Text(stringResource(R.string.share), modifier = Modifier.padding(4.dp)) + } + } + + // NOTE(#2539): Avoid crash when there are no non-LOI tasks. + val showAddData = canUserSubmitData && loi.job.hasNonLoiTasks() && loi.isPredefined == true + if (showAddData) { + Button(modifier = Modifier.padding(start = 8.dp), onClick = onCollectClicked) { + Text(stringResource(R.string.add_data), modifier = Modifier.padding(4.dp)) + } } } } @@ -243,8 +271,11 @@ private fun PreviewModalContentsWhenJobHasNoTasks() { canUserSubmitData = true, submissionCount = 0, showDeleteLoiButton = false, + showShareButton = true, onDeleteClicked = null, - ) {} + onShareClicked = {}, + onCollectClicked = {}, + ) } } @@ -285,8 +316,11 @@ private fun PreviewModalContentsWhenUserCannotSubmitData() { canUserSubmitData = false, submissionCount = 1, showDeleteLoiButton = false, + showShareButton = true, onDeleteClicked = null, - ) {} + onShareClicked = {}, + onCollectClicked = {}, + ) } } @@ -329,8 +363,11 @@ private fun PreviewModalContentsWhenJobHasTasks() { canUserSubmitData = true, submissionCount = 20, showDeleteLoiButton = false, + showShareButton = true, onDeleteClicked = null, - ) {} + onShareClicked = {}, + onCollectClicked = {}, + ) } } @@ -372,8 +409,11 @@ private fun PreviewModalContentsWhenJobHasTasksAndIsPredefined() { loi = loi, canUserSubmitData = true, submissionCount = 20, - showDeleteLoiButton = false, + showDeleteLoiButton = true, + showShareButton = true, onDeleteClicked = null, - ) {} + onShareClicked = {}, + onCollectClicked = {}, + ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt new file mode 100644 index 0000000000..165d80bba9 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -0,0 +1,119 @@ +/* + * 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.home.mapcontainer.jobs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.groundplatform.android.R +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.qrcode.GroundQrCode +import org.groundplatform.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.share_location), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Normal, + ) + + Box( + modifier = + Modifier.fillMaxWidth() + .padding(vertical = 16.dp) + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + GroundQrCode( + modifier = Modifier.align(Alignment.Center), + title = loiReport.loiName, + footer = stringResource(R.string.scan_this_qr_to_download_geojson), + content = loiReport.geoJson.toString(), + contentDescription = "QR code with LOI Geometry", + centerLogoPainter = painterResource(R.drawable.ground_logo), + ) + } + + TextButton( + modifier = Modifier.align(Alignment.End).padding(top = 16.dp), + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.close)) + } + } + } + } +} + +@Preview +@Composable +private fun ShareLocationModalPreview() { + val testLoiReport = + LoiReport( + loiName = "Test LOI", + geoJson = + JsonObject( + mapOf( + "type" to JsonPrimitive("Feature"), + "properties" to JsonObject(mapOf("name" to JsonPrimitive("Point test"))), + "geometry" to + JsonObject( + mapOf( + "type" to JsonPrimitive("Point"), + "coordinates" to JsonArray(listOf(JsonPrimitive(-89.0), JsonPrimitive(41.0))), + ) + ), + ) + ), + ) + + AppTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ShareLocationModal(loiReport = testLoiReport, onDismiss = {}) + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/usecases/submission/SubmitDataUseCase.kt b/app/src/main/java/org/groundplatform/android/usecases/submission/SubmitDataUseCase.kt index 608ceb6ec7..fe2ae391f3 100644 --- a/app/src/main/java/org/groundplatform/android/usecases/submission/SubmitDataUseCase.kt +++ b/app/src/main/java/org/groundplatform/android/usecases/submission/SubmitDataUseCase.kt @@ -48,12 +48,13 @@ constructor( deltas: List, loiName: String?, collectionId: String, - ) { + ): String { Timber.v("Submitting data for LOI: $selectedLoiId") val deltasToSubmit = deltas.toMutableList() val submissionLoiId = selectedLoiId ?: addLocationOfInterest(surveyId, job, deltasToSubmit, loiName, collectionId) submissionRepository.saveSubmission(surveyId, submissionLoiId, deltasToSubmit, collectionId) + return submissionLoiId } /** diff --git a/app/src/main/res/drawable/data_submitted.xml b/app/src/main/res/drawable/data_submitted.xml deleted file mode 100644 index b1f5f1ed3c..0000000000 --- a/app/src/main/res/drawable/data_submitted.xml +++ /dev/null @@ -1,1068 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/data_collection_frag.xml b/app/src/main/res/layout/data_collection_frag.xml index a91e3a191a..b0191bd37f 100644 --- a/app/src/main/res/layout/data_collection_frag.xml +++ b/app/src/main/res/layout/data_collection_frag.xml @@ -16,57 +16,43 @@ ~ limitations under the License. --> - - - - - - - - + + - - - - - - - - + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:navigationIcon="@drawable/baseline_close_24" + app:subtitleCentered="true" /> + + + + + + - - + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8039f6d9a3..a9255fe10c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -184,7 +184,6 @@ Datos no guardados restaurados No se pudo conectar, intente de nuevo más tarde - Datos de imagen enviados ¡Recolección de datos completada! Sus datos se han guardado y se sincronizarán automáticamente cuando esté en línea. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07b69a30ea..6cda5873a4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -171,7 +171,6 @@ Données non sauvegardées ont été restaurées Impossible de se connecter, réessayer plus tard - Données d’image soumises Collecte des données terminée ! Vos données ont été sauvegardées et seront automatiquement synchronisées lorsque vous serez en ligne. Autoriser la localisation diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 21072161b3..08ecd68150 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -167,7 +167,6 @@ \nຜູ້ຈັດການແບບສຳຫຼວດສາມາດແບ່ງປັນ ແລະ ນຳໃຊ້ຂໍ້ມູນຕໍ່ສາທາລະນະ ພາຍໃຕ້ໃບອະນຸຍາດ *Creative Commons CC0 1.0* ກູ້ຄືນຂໍ້ມູນທີ່ຍັງບໍ່ໄດ້ບັນທຶກ ບໍ່ສາມາດເຊື່ອມຕໍ່ໄດ້ ກະລຸນາລອງໃໝ່ໃນພາຍຫຼັງ - ຮູບພາບການສົ່ງຂໍ້ມູນ ການເກັບຂໍ້ມູນສຳເລັດແລ້ວ! ຂໍ້ມູນຂອງທ່ານຖືກບັນທຶກແລ້ວ ແລະ ຈະຖືກຊິງອັດຕະໂນມັດເມື່ອທ່ານອອນລາຍ ອະນຸຍາດການລະບຸຕຳແໜ່ງ diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f71f162528..8ce4c54cbf 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -185,7 +185,6 @@ Os dados não salvos foram restaurados Não foi possível conectar, tente novamente mais tarde - Dados da imagem enviada Coleta de dados concluída! Seus dados foram salvos e serão sincronizados automaticamente quando você estiver online. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 2ff810b821..3982d98ece 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -169,7 +169,6 @@ บันทึกข้อมูลที่ยังไม่ได้บันทึกถูกกู้คืนแล้ว ไม่สามารถเชื่อมต่อได้ กรุณาลองใหม่ภายหลัง - ภาพการส่งข้อมูล การเก็บข้อมูลเสร็จสมบูรณ์! ข้อมูลของคุณถูกบันทึกแล้ว และจะซิงค์อัตโนมัติเมื่อคุณเชื่อมต่ออินเทอร์เน็ต อนุญาตการเข้าถึงตำแหน่ง diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a7b042b342..f433905e07 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -166,7 +166,6 @@ \nNgười tổ chức khảo sát có thể chia sẻ và sử dụng dữ liệu công khai theo Giấy phép *Creative Commons CC0 1.0* Dữ liệu chưa lưu đã được khôi phục Không thể kết nối, vui lòng thử lại sau - Ảnh đã được gửi kèm dữ liệu Thu thập dữ liệu hoàn tất! Dữ liệu của bạn đã được lưu và sẽ tự động đồng bộ khi bạn trực tuyến. Cho phép truy cập vị trí diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae50c9985b..b0bcb94950 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,7 +184,6 @@ Unsaved data restored Could not connect, try again later - Data submitted image Data collection complete! Your data has been saved and will be automatically synced when you’re online. @@ -238,4 +237,7 @@ Try standing still or waiting for a more accurate position Re-center + Share location + Scan this QR code to view the GeoJson + Share diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 1597514d8e..10a477d016 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -16,6 +16,10 @@ package org.groundplatform.android import kotlin.time.Clock +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import org.groundplatform.android.model.imagery.OfflineArea import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.gms.features.FeatureClusterItem @@ -32,6 +36,7 @@ import org.groundplatform.domain.model.job.Style import org.groundplatform.domain.model.locationofinterest.AuditInfo import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.mutation.LocationOfInterestMutation import org.groundplatform.domain.model.mutation.Mutation @@ -109,6 +114,24 @@ object FakeData { geometry = Point(Coordinates(0.0, 0.0)), ) + val LOCATION_OF_INTEREST_LOI_REPORT = + LoiReport( + loiName = "Unnamed point", + geoJson = + JsonObject( + mapOf( + "type" to JsonPrimitive("Feature"), + "properties" to buildJsonObject {}, + "geometry" to + JsonObject( + mapOf( + "type" to JsonPrimitive("Point"), + "coordinates" to JsonArray(listOf(JsonPrimitive(0.0), JsonPrimitive(0.0))), + ) + ), + ) + ), + ) val LOCATION_OF_INTEREST_FEATURE = Feature( id = LOCATION_OF_INTEREST.id, diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt index 450acdff44..bdf1fdd95b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt @@ -38,6 +38,7 @@ import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.getString import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.repository.MutationRepository import org.groundplatform.android.repository.SubmissionRepository @@ -60,6 +61,7 @@ import org.groundplatform.domain.model.task.Option import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -315,7 +317,7 @@ class DataCollectionFragmentTest : BaseHiltTest() { } @Test - fun `Clicking done on final task hides the navigation close button`() = runWithTestDispatcher { + fun `Clicking done on final task displays the close button and title`() = runWithTestDispatcher { setupFragment() runner() @@ -326,7 +328,10 @@ class DataCollectionFragmentTest : BaseHiltTest() { .selectOption(TASK_2_OPTION_LABEL) .clickDoneButton() // Click "done" on final task - assertThat(getToolbar()?.navigationIcon).isNull() + advanceUntilIdle() + + assertThat(getToolbar()?.navigationIcon).isNotNull() + assertThat(getToolbar()?.title).isEqualTo(getString(R.string.data_collection_complete)) } @Test @@ -652,9 +657,11 @@ class DataCollectionFragmentTest : BaseHiltTest() { .selectOption(TASK_2_OPTION_LABEL) .clickDoneButton() + advanceUntilIdle() + // Simulate state after task submission val state = fragment.viewModel.uiState.value - assertThat(state).isEqualTo(DataCollectionUiState.TaskSubmitted) + assertTrue(state is DataCollectionUiState.TaskSubmitted) } @Test diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index addf25a6ec..6dff83cb88 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -29,6 +29,7 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_FEATURE +import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_LOI_REPORT import org.groundplatform.android.FakeData.SURVEY import org.groundplatform.android.FakeData.USER import org.groundplatform.android.data.remote.FakeRemoteDataStore @@ -48,7 +49,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @@ -76,8 +78,9 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { remoteDataStore.predefinedLois = listOf(LOCATION_OF_INTEREST) activateSurvey(SURVEY.id) advanceUntilIdle() - `when`(loiRepository.getWithinBounds(SURVEY, BOUNDS)) + whenever(loiRepository.getWithinBounds(SURVEY, BOUNDS)) .thenReturn(flowOf(listOf(LOCATION_OF_INTEREST))) + whenever(loiRepository.getOfflineLoi(any(), any())).thenReturn(LOCATION_OF_INTEREST) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() } @@ -87,10 +90,17 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { fun `renders the job card when zoomed into LOI and clicked on`() = runWithTestDispatcher { viewModel.onFeatureClicked(features = setOf(LOCATION_OF_INTEREST_FEATURE)) val state = viewModel.processJobMapComponentState().first() + advanceUntilIdle() assertThat(state) .isEqualTo( JobMapComponentState.LoiSelected( - SelectedLoiSheetData(canCollectData = true, LOCATION_OF_INTEREST, 0, true) + SelectedLoiSheetData( + canCollectData = true, + loi = LOCATION_OF_INTEREST, + submissionCount = 0, + showDeleteLoiButton = true, + loiReport = LOCATION_OF_INTEREST_LOI_REPORT, + ) ) ) } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt index a1dd13f679..46926dffa5 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt @@ -27,6 +27,7 @@ import kotlin.test.assertTrue import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.FakeData.JOB import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST +import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_LOI_REPORT import org.groundplatform.android.FakeData.newTask import org.groundplatform.android.R import org.groundplatform.android.getString @@ -148,7 +149,13 @@ class JobMapComponentTest { @Test fun `LoiJobSheet should be shown when there is a selected LOI`() { val selectedLoiSheetData = - SelectedLoiSheetData(canCollectData = true, LOCATION_OF_INTEREST, 0, true) + SelectedLoiSheetData( + canCollectData = true, + LOCATION_OF_INTEREST, + 0, + true, + LOCATION_OF_INTEREST_LOI_REPORT, + ) setContent(JobMapComponentState.LoiSelected(selectedLoiSheetData)) composeTestRule @@ -161,7 +168,13 @@ class JobMapComponentTest { fun `Clicking to delete site in the LoiJobSheet should dispatch the OnDeleteSiteClicked action`() { val performedActions = mutableListOf() val selectedLoiSheetData = - SelectedLoiSheetData(canCollectData = true, LOCATION_OF_INTEREST, 0, true) + SelectedLoiSheetData( + canCollectData = true, + LOCATION_OF_INTEREST, + 0, + true, + LOCATION_OF_INTEREST_LOI_REPORT, + ) setContent( state = JobMapComponentState.LoiSelected(selectedLoiSheetData), onAction = { performedActions += it }, @@ -191,6 +204,7 @@ class JobMapComponentTest { ), submissionCount = 20, showDeleteLoiButton = false, + loiReport = LOCATION_OF_INTEREST_LOI_REPORT, ) setContent( state = JobMapComponentState.LoiSelected(selectedLoiSheetData), diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt index b8f433921e..ad6ee20374 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt @@ -20,6 +20,9 @@ import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import kotlin.test.Test +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.FakeData import org.groundplatform.android.FakeData.USER import org.groundplatform.android.R @@ -30,6 +33,7 @@ import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style import org.groundplatform.domain.model.locationofinterest.AuditInfo import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.task.Task import org.junit.Rule import org.junit.runner.RunWith @@ -84,15 +88,40 @@ class LoiJobSheetTest { private fun setContent(loi: LocationOfInterest, showDeleteLoiButton: Boolean = false) { composeTestRule.setContent { LoiJobSheet( - loi = loi, - canUserSubmitData = true, - submissionCount = 0, - showDeleteLoiButton = showDeleteLoiButton, + state = + SelectedLoiSheetData( + canCollectData = true, + submissionCount = 0, + loi = loi, + showDeleteLoiButton = showDeleteLoiButton, + loiReport = getLoiReport(loi.id), + ), onCollectClicked = {}, - ) {} + onDismiss = {}, + onShareClicked = {}, + ) } } + private fun getLoiReport(name: String): LoiReport = + LoiReport( + loiName = name, + geoJson = + JsonObject( + mapOf( + "type" to JsonPrimitive("Feature"), + "properties" to JsonObject(mapOf("name" to JsonPrimitive(name))), + "geometry" to + JsonObject( + mapOf( + "type" to JsonPrimitive("Point"), + "coordinates" to JsonArray(listOf(JsonPrimitive(20.0), JsonPrimitive(20.0))), + ) + ), + ) + ), + ) + companion object { private val NO_TASK_LOI = LocationOfInterest( diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt new file mode 100644 index 0000000000..d51c63d323 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt @@ -0,0 +1,66 @@ +/* + * 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.home.mapcontainer.jobs + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import kotlin.test.Test +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ShareLocationModalTest { + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `Modal is displayed correctly and shows the QR code with the LOI geometry`() { + composeTestRule.setContent { + AppTheme { ShareLocationModal(loiReport = LoiReport(LOI_NAME, LOI_GEO_JSON), onDismiss = {}) } + } + composeTestRule.onNodeWithText(getString(R.string.share_location)).assertIsDisplayed() + composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed() + composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed() + } + + private companion object { + const val LOI_NAME = "Test Loi" + val LOI_GEO_JSON = + JsonObject( + mapOf( + "type" to JsonPrimitive("Feature"), + "properties" to JsonObject(mapOf("name" to JsonPrimitive(LOI_NAME))), + "geometry" to + JsonObject( + mapOf( + "type" to JsonPrimitive("Point"), + "coordinates" to JsonArray(listOf(JsonPrimitive(20.0), JsonPrimitive(20.0))), + ) + ), + ) + ) + } +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt index bd5a30d06c..c0dc8a99f9 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt @@ -18,4 +18,4 @@ package org.groundplatform.domain.model.locationofinterest import kotlinx.serialization.json.JsonObject /** Represents the data collected for a specific LOI which can be downloaded and shared. */ -data class LoiReport(val geoJson: JsonObject) +data class LoiReport(val loiName: String, val geoJson: JsonObject) diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt index 0a8286eeda..bee7a36003 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.domain.usecases +import kotlin.math.round import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -26,6 +27,7 @@ import org.groundplatform.domain.model.geometry.LinearRing import org.groundplatform.domain.model.geometry.MultiPolygon import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.model.geometry.Polygon +import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LoiProperties import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface @@ -45,9 +47,16 @@ class GetLoiReportUseCase( * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ - suspend operator fun invoke(loiId: String, surveyId: String): LoiReport? { + suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) - return loi?.let { LoiReport(it.geometry.toGeoJson(it.properties)) } + return loi?.let { + LoiReport( + loiName, + it.geometry.toGeoJson( + it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } + ), + ) + } } /** @@ -89,7 +98,12 @@ class GetLoiReportUseCase( /** Converts a single [Coordinates] to a GeoJSON position: [lng, lat]. */ private fun coordinatesToPosition(coordinates: Coordinates): JsonArray = - JsonArray(listOf(JsonPrimitive(coordinates.lng), JsonPrimitive(coordinates.lat))) + JsonArray( + listOf( + JsonPrimitive(coordinates.lng.roundTo6Decimals()), + JsonPrimitive(coordinates.lat.roundTo6Decimals()), + ) + ) /** Converts a list of [Coordinates] to a GeoJSON array of positions. */ private fun coordinatesToPositions(coordinates: List): JsonArray = @@ -102,6 +116,8 @@ class GetLoiReportUseCase( return JsonArray(rings) } + private fun Double.roundTo6Decimals(): Double = round(this * 1e6) / 1e6 + private companion object { const val KEY_TYPE = "type" const val TYPE_FEATURE = "Feature" diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt index ae4e4d295f..8829c990fb 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt @@ -242,9 +242,67 @@ class GetLoiReportUseCaseTest { } } + @Test + fun `Should round coordinates to 6 decimals`() = runTest { + val lineString = + LineString( + listOf( + Coordinates(1.123456789, 2.987654321), + Coordinates(3.123456789, 4.987654321), + Coordinates(5.123456789, 6.987654321), + ) + ) + val loiReport = + invokeUseCase(geometry = lineString, properties = generateProperties("Rounding test")) + + val expectedGeoJson = + """ + { + "type": "Feature", + "properties": {"name": "Rounding test"}, + "geometry": { + "type": "LineString", + "coordinates": [ + [2.987654, 1.123457], + [4.987654, 3.123457], + [6.987654, 5.123457] + ] + } + } + """ + .trimIndent() + + assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + } + + @Test + fun `Should only include name property even if more are provided`() = runTest { + val properties = + generateProperties("Name test") + + mapOf("description" to "Should be removed", "extra" to "Also removed") + + val loiReport = + invokeUseCase(geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), properties = properties) + + val expectedGeoJson = + """ + { + "type": "Feature", + "properties": {"name": "Name test"}, + "geometry": { + "type": "Point", + "coordinates": [0.0, 0.0] + } + } + """ + .trimIndent() + + assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + } + private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { loiRepository.offlineLoi = loiRepository.offlineLoi.copy(geometry = geometry, properties = properties) - return getLoiReportUseCase.invoke("loiId", "surveyId")!! + return getLoiReportUseCase.invoke("loiName", "loiId", "surveyId")!! } } diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt index 6d779d805d..10093dbecb 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt @@ -15,15 +15,19 @@ */ package org.groundplatform.ui.components.qrcode +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.layout.sizeIn import androidx.compose.material3.CircularProgressIndicator 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.produceState @@ -33,12 +37,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.groundplatform.ui.theme.sizes +@VisibleForTesting const val TEST_TAG_GROUND_QR_CODE = "TEST_TAG_GROUND_QR_CODE" + /** * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. * @@ -65,9 +73,11 @@ private const val LOGO_SIZE_FRACTION = 0.15f @Composable fun GroundQrCode( modifier: Modifier = Modifier, + title: String, content: String, contentDescription: String, centerLogoPainter: Painter?, + footer: String, ) { val contentBytes = remember(content) { content.encodeToByteArray().size } val showLogo = centerLogoPainter != null && contentBytes <= MAX_QR_BYTES_WITH_LOGO @@ -77,30 +87,46 @@ fun GroundQrCode( value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) } } - Box(modifier = modifier.sizeIn(minWidth = 142.dp, minHeight = 142.dp)) { - qrBitmap?.let { - Image( - modifier = Modifier.align(Alignment.Center), - bitmap = it, - contentDescription = contentDescription, - contentScale = ContentScale.Fit, - ) - if (showLogo) { + Column( + modifier = modifier.fillMaxWidth().testTag(TEST_TAG_GROUND_QR_CODE), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box(modifier = modifier.sizeIn(minWidth = 142.dp, minHeight = 142.dp)) { + qrBitmap?.let { Image( - painter = centerLogoPainter, - contentDescription = null, - modifier = Modifier.align(Alignment.Center).fillMaxSize(LOGO_SIZE_FRACTION), + modifier = Modifier.align(Alignment.Center), + bitmap = it, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, ) + if (showLogo) { + Image( + painter = centerLogoPainter, + contentDescription = null, + modifier = Modifier.align(Alignment.Center).fillMaxSize(LOGO_SIZE_FRACTION), + ) + } } + ?: run { + CircularProgressIndicator( + modifier = + Modifier.size(MaterialTheme.sizes.progressIndicatorSize).align(Alignment.Center), + color = MaterialTheme.colorScheme.primary, + strokeWidth = MaterialTheme.sizes.progressIndicatorStrokeWidth, + ) + } } - ?: run { - CircularProgressIndicator( - modifier = - Modifier.size(MaterialTheme.sizes.progressIndicatorSize).align(Alignment.Center), - color = MaterialTheme.colorScheme.primary, - strokeWidth = MaterialTheme.sizes.progressIndicatorStrokeWidth, - ) - } + Text( + text = footer, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -111,8 +137,10 @@ private fun GroundQrCodePreview() { Surface { GroundQrCode( modifier = Modifier.padding(16.dp).size(400.dp), + title = "Test QR code", content = "https://www.google.com", contentDescription = "Google", + footer = "Scan this QR", centerLogoPainter = null, ) }