diff --git a/app/build.gradle b/app/build.gradle index 8baeaef88b..d59fd2ec5e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -190,6 +190,7 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':core:domain') implementation project(':core:ui') + implementation project(':feature:pdf') implementation libs.androidx.multidex implementation libs.androidx.preference.ktx diff --git a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt index 220304232a..6ecfed8340 100644 --- a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt @@ -27,6 +27,10 @@ import java.util.Locale import javax.inject.Singleton import org.groundplatform.android.R import org.groundplatform.android.util.SurveyDeepLinkParser +import org.groundplatform.ui.util.AndroidDateFormatter +import org.groundplatform.ui.util.ComposeStringResolver +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver @InstallIn(SingletonComponent::class) @Module(includes = [ViewModelModule::class]) @@ -47,4 +51,11 @@ object GroundApplicationModule { deepLinkHost = resources.getString(R.string.deeplink_host), deepLinkPath = resources.getString(R.string.survey_deeplink_path), ) + + @Provides + @Singleton + fun provideDateFormatter(@ApplicationContext context: Context): DateFormatter = + AndroidDateFormatter(context) + + @Provides @Singleton fun provideStringResolver(): StringResolver = ComposeStringResolver } diff --git a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt new file mode 100644 index 0000000000..e3badfb4b3 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt @@ -0,0 +1,112 @@ +/* + * 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.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import org.groundplatform.android.BuildConfig +import org.groundplatform.android.R +import org.groundplatform.android.di.coroutines.DefaultDispatcher +import org.groundplatform.android.di.coroutines.IoDispatcher +import org.groundplatform.feature.pdf.AndroidPdfImageProvider +import org.groundplatform.feature.pdf.AndroidPdfOutputProvider +import org.groundplatform.feature.pdf.AndroidPdfRenderer +import org.groundplatform.feature.pdf.AndroidPdfReportLauncher +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver + +@Module +@InstallIn(SingletonComponent::class) +object PdfModule { + + @Provides + @Singleton + fun providePdfImageProvider(@ApplicationContext context: Context): PdfImageProvider = + AndroidPdfImageProvider(context = context, logoDrawableRes = R.drawable.ground_logo) + + @Provides + @Singleton + fun providePdfOutputFactory(@ApplicationContext context: Context): PdfOutputProvider = + AndroidPdfOutputProvider(context) + + @Provides + @Singleton + fun providePdfRenderer(@IoDispatcher ioDispatcher: CoroutineDispatcher): PdfRenderer = + AndroidPdfRenderer(ioDispatcher) + + @Provides + @Singleton + fun provideTaskValueMapper( + strings: StringResolver, + dateFormatter: DateFormatter, + ): TaskValueMapper = TaskValueMapper(strings = strings, dateFormatter = dateFormatter) + + @Provides + @Singleton + fun provideLoiReportMapper( + taskValueMapper: TaskValueMapper, + strings: StringResolver, + dateFormatter: DateFormatter, + ): LoiReportMapper = + LoiReportMapper( + taskValueMapper = taskValueMapper, + strings = strings, + dateFormatter = dateFormatter, + ) + + @Provides + @Singleton + fun providePdfReportLauncher(@ApplicationContext context: Context): PdfReportLauncher = + AndroidPdfReportLauncher(context = context, fileProviderAuthority = BuildConfig.APPLICATION_ID) + + @Provides + @Singleton + fun providePdfReportService( + imageProvider: PdfImageProvider, + renderer: PdfRenderer, + outputProvider: PdfOutputProvider, + launcher: PdfReportLauncher, + @DefaultDispatcher coroutineDispatcher: CoroutineDispatcher, + ): PdfExportService = + PdfExportService( + imageProvider = imageProvider, + renderer = renderer, + outputProvider = outputProvider, + launcher = launcher, + coroutineDispatcher = coroutineDispatcher, + ) + + @Provides + @Singleton + fun provideLoiReportExporter( + mapper: LoiReportMapper, + exportService: PdfExportService, + ): LoiReportExporter = LoiReportExporter(mapper = mapper, exportService = exportService) +} diff --git a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt index 34c20a224d..ea84061442 100644 --- a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt @@ -38,7 +38,14 @@ object UseCaseModule { locationOfInterestRepository: LocationOfInterestRepositoryInterface, userRepository: UserRepositoryInterface, surveyRepository: SurveyRepositoryInterface, - ) = GetLoiReportUseCase(locationOfInterestRepository, userRepository, surveyRepository) + submissionRepository: SubmissionRepositoryInterface, + ) = + GetLoiReportUseCase( + locationOfInterestRepository = locationOfInterestRepository, + userRepositoryInterface = userRepository, + surveyRepositoryInterface = surveyRepository, + submissionRepositoryInterface = submissionRepository, + ) @Provides fun providesUpdateUserSettingsUseCase(userRepository: UserRepositoryInterface) = diff --git a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt index 12b9c9dfc9..8295ea7f01 100644 --- a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt @@ -30,8 +30,14 @@ object CoroutineDispatchersModule { @IoDispatcher @Provides fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO @MainDispatcher @Provides fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default } +@Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class DefaultDispatcher + @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class IoDispatcher @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class MainDispatcher diff --git a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt index e03fab65d1..352cbfdf34 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt @@ -112,4 +112,7 @@ constructor( private suspend fun getPendingDeleteCount(loiId: String) = localSubmissionStore.getPendingDeleteCount(loiId) + + override suspend fun getSubmissions(loi: LocationOfInterest) = + localSubmissionStore.getSubmissions(loi, loi.job.id) } 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 ee0dee8c17..bea2ecab49 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 @@ -55,6 +55,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, + onReportExportError = { popups.ErrorPopup().unknownError() }, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt index 766035e189..7edc08f9bf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.groundplatform.android.R import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer +import org.groundplatform.ui.components.loireport.LoiReportAction /** * The main screen for data collection, coordinating the task sequence and host UI. @@ -57,6 +58,7 @@ import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer fun DataCollectionScreen( viewModel: DataCollectionViewModel, onValidationError: (resId: Int) -> Unit, + onReportExportError: () -> Unit, onExitConfirmed: () -> Unit, onOpenSettings: () -> Unit, onAwaitingPhotoCapture: (Boolean) -> Unit, @@ -71,11 +73,16 @@ fun DataCollectionScreen( is DataCollectionUiEffect.OpenSettings -> onOpenSettings() is DataCollectionUiEffect.SetAwaitingPhotoCapture -> onAwaitingPhotoCapture(effect.awaiting) is DataCollectionUiEffect.ShowValidationError -> onValidationError(effect.errorResId) + is DataCollectionUiEffect.ShowReportExportError -> onReportExportError() } } } - DataCollectionContent(uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }) { + DataCollectionContent( + uiState = uiState, + onCloseClicked = { viewModel.onCloseClicked() }, + onLoiReportAction = { viewModel.onLoiReportAction(it) }, + ) { readyState -> val tasks = readyState.tasks if (tasks.isNotEmpty()) { @@ -134,6 +141,7 @@ object DataCollectionScreenTestTags { fun DataCollectionContent( uiState: DataCollectionUiState, onCloseClicked: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, pagerContent: @Composable (DataCollectionUiState.Ready) -> Unit, ) { Scaffold(topBar = { DataCollectionToolbar(uiState, onCloseClicked) }) { innerPadding -> @@ -153,7 +161,11 @@ fun DataCollectionContent( ReadyContent { pagerContent(uiState) } } is DataCollectionUiState.TaskSubmitted -> { - DataSubmissionConfirmationScreen(loiReport = uiState.loiReport) { onCloseClicked() } + DataSubmissionConfirmationScreen( + loiReport = uiState.loiReport, + onLoiReportAction = onLoiReportAction, + onDismissed = onCloseClicked, + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt index bd1c6946a9..10b2b1c88a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt @@ -29,6 +29,7 @@ import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock private const val PAGER_CONTENT_TEXT = "Pager Content Area" @@ -37,7 +38,11 @@ private const val PAGER_CONTENT_TEXT = "Pager Content Area" @ExcludeFromJacocoGeneratedReport private fun DataCollectionContentLoadingPreview() { AppTheme { - DataCollectionContent(uiState = DataCollectionUiState.Loading, onCloseClicked = {}) { + DataCollectionContent( + uiState = DataCollectionUiState.Loading, + onCloseClicked = {}, + onLoiReportAction = {}, + ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) } @@ -57,6 +62,7 @@ private fun DataCollectionContentErrorPreview() { cause = Error("Some error"), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -82,6 +88,7 @@ private fun DataCollectionContentPreview() { position = TaskPosition(0, 1, 3), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -99,9 +106,21 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), submissionDetails = null) + LoiReport( + loiName = "Point A", + geoJson = JsonObject(mapOf()), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), + ) ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) 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 1a0c3fcd3c..c99116a8e3 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 @@ -59,6 +59,8 @@ import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase import org.groundplatform.domain.usecases.submission.SubmitDataUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import timber.log.Timber sealed interface DataCollectionUiEffect { @@ -69,6 +71,8 @@ sealed interface DataCollectionUiEffect { data class SetAwaitingPhotoCapture(val awaiting: Boolean) : DataCollectionUiEffect data class ShowValidationError(val errorResId: Int) : DataCollectionUiEffect + + data object ShowReportExportError : DataCollectionUiEffect } /** View model for the Data Collection fragment. */ @@ -86,6 +90,7 @@ internal constructor( private val viewModelFactory: ViewModelFactory, private val dataCollectionInitializer: DataCollectionInitializer, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : AbstractViewModel() { private val _uiEffects = Channel(Channel.BUFFERED) @@ -215,6 +220,15 @@ internal constructor( } } + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = (uiState.value as? DataCollectionUiState.TaskSubmitted)?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(DataCollectionUiEffect.ShowReportExportError) + } + } + } + fun handleLoiNameAction(action: LoiNameAction, taskId: String) { when (action) { is LoiNameAction.Confirmed -> { 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 0b18ea8b3a..039c4c9b11 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 @@ -58,9 +58,11 @@ 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.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @Composable @@ -68,6 +70,7 @@ fun DataSubmissionConfirmationScreen( modifier: Modifier = Modifier, loiReport: LoiReport? = null, onDismissed: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { val baseModifier = modifier @@ -88,12 +91,16 @@ fun DataSubmissionConfirmationScreen( } } Spacer(modifier = Modifier.width(16.dp)) - ShareableContent(modifier = Modifier.weight(1f), loiReport = loiReport) + ShareableContent( + modifier = Modifier.weight(1f), + loiReport = loiReport, + onLoiReportAction = onLoiReportAction, + ) } } else { Column(modifier = baseModifier, horizontalAlignment = Alignment.CenterHorizontally) { HeaderContent(modifier = Modifier.padding(vertical = 16.dp)) - ShareableContent(loiReport = loiReport) + ShareableContent(loiReport = loiReport, onLoiReportAction = onLoiReportAction) OutlinedButton(modifier = Modifier.padding(vertical = 24.dp), onClick = { onDismissed() }) { Text( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -136,7 +143,11 @@ private fun HeaderContent(modifier: Modifier = Modifier) { } @Composable -private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport?) { +private fun ShareableContent( + modifier: Modifier = Modifier, + loiReport: LoiReport?, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current loiReport?.let { @@ -164,21 +175,15 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } } } @@ -201,19 +206,38 @@ private val testLoiReport = ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) @Composable @Preview(showSystemUi = true) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenPortraitPreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } @Composable @Preview(heightDp = 320, widthDp = 800) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenLandscapePreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt index 432ccdf284..f249c00f20 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -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 @@ -39,12 +38,10 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY -import timber.log.Timber /** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */ @AndroidEntryPoint @@ -64,13 +61,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { map.featureClicks.launchWhenStartedAndCollect { mapContainerViewModel.onFeatureClicked(it) } } - private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = - when (cardUiData) { - // LOI tasks are filtered out of the tasks list for pre-defined tasks. - is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 - is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() - } - private fun showDataSharingTermsDialog( cardUiData: DataCollectionEntryPointData, dataSharingTerms: Survey.DataSharingTerms, @@ -83,45 +73,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - /** Invoked when user clicks on the map cards to collect data. */ - private fun onCollectData(cardUiData: DataCollectionEntryPointData) { - if (!cardUiData.canCollectData) { - // Skip data collection screen if the user can't submit any data - // TODO: Revisit UX for displaying view only mode - // Issue URL: https://github.com/google/ground-android/issues/1667 - ephemeralPopups.ErrorPopup().show(getString(R.string.collect_data_viewer_error)) - return - } - if (!hasValidTasks(cardUiData)) { - // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. - ephemeralPopups.ErrorPopup().show(getString(R.string.no_tasks_error)) - return - } - - mapContainerViewModel - .getDataSharingTerms() - .onSuccess { terms -> - if (terms == null) { - // Data sharing terms already accepted or missing. - navigateToDataCollectionFragment(cardUiData) - } else { - showDataSharingTermsDialog(cardUiData, terms) - } - } - .onFailure { - Timber.e(it, "Failed to get data sharing terms") - ephemeralPopups - .ErrorPopup() - .show( - if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { - R.string.invalid_data_sharing_terms - } else { - R.string.something_went_wrong - } - ) - } - } - /** Invoked when user clicks delete on a site. */ private fun onDeleteSite(loiData: SelectedLoiSheetData) { mapContainerViewModel.deleteLoi(loiData.loi) @@ -160,12 +111,14 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { onJobComponentAction = { handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) }, + onLoiReportAction = { mapContainerViewModel.onLoiReportAction(it) }, ) } } binding.bottomContainer.bringToFront() - showDataCollectionHint() + mapContainerViewModel.uiEffects.launchWhenStartedAndCollect { handleUiEffect(it) } + mapContainerViewModel.showDataCollectionHint() // LOIs associated with the survey have been synced to the local db by this point. We can // enable location lock if no LOIs exist or a previous camera position doesn't exist. @@ -186,7 +139,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { ) { when (action) { is JobMapComponentAction.OnAddDataClicked -> { - onCollectData(action.selectedLoi) + mapContainerViewModel.onCollectData(action.selectedLoi) } is JobMapComponentAction.OnDeleteSiteClicked -> { onDeleteSite(action.selectedLoi) @@ -199,10 +152,12 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { val jobs = (jobMapComponentState as? JobMapComponentState.AddLoiButton)?.jobs ?: (jobMapComponentState as? JobMapComponentState.JobSelectionModal)?.jobs - jobs?.firstOrNull { it.job == action.job }?.let { onCollectData(it) } + jobs?.firstOrNull { it.job == action.job }?.let { mapContainerViewModel.onCollectData(it) } } is JobMapComponentAction.OnAddLoiButtonClicked -> { - mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { onCollectData(it) } + mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { + mapContainerViewModel.onCollectData(it) + } } JobMapComponentAction.OnJobSelectionModalDismissed -> { mapContainerViewModel.setJobSelectionModalVisibility(false) @@ -210,33 +165,18 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - /** - * Displays a popup hint informing users how to begin collecting data. - * - * This method should only be called after view creation and should only trigger once per view - * create. - */ - private fun showDataCollectionHint() { - if (!this::mapContainerViewModel.isInitialized) { - return Timber.w("showDataCollectionHint() called before mapContainerViewModel initialized") - } - if (!this::binding.isInitialized) { - return Timber.w("showDataCollectionHint() called before binding initialized") - } - - // Decides which survey-related popup to show based on the current survey. - mapContainerViewModel.surveyUpdateFlow.launchWhenStartedAndCollectFirst { surveyProperties -> - surveyProperties.getInfoPopupMessageId()?.let { showInfoPopup(it) } + private fun handleUiEffect(event: HomeScreenMapContainerUiEffect) { + when (event) { + is HomeScreenMapContainerUiEffect.ShowError -> + ephemeralPopups.ErrorPopup().show(event.messageId) + is HomeScreenMapContainerUiEffect.ShowInfo -> showInfoPopup(event.messageId) + is HomeScreenMapContainerUiEffect.NavigateToDataCollection -> + navigateToDataCollectionFragment(event.data) + is HomeScreenMapContainerUiEffect.ShowDataSharingTerms -> + showDataSharingTermsDialog(event.data, event.terms) } } - private fun HomeScreenMapContainerViewModel.SurveyProperties.getInfoPopupMessageId(): Int? = - if (noLois && !addLoiPermitted) { - R.string.read_only_data_collection_hint - } else { - null - } - private fun showInfoPopup(messageId: Int) { ephemeralPopups .InfoPopup(binding.bottomContainer, messageId, EphemeralPopups.PopupDuration.LONG) diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt index 1e83325ea0..e56cc9bb7b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt @@ -42,6 +42,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.theme.AppTheme @Composable @@ -53,6 +54,7 @@ fun HomeScreenMapContainerScreen( jobComponentState: JobMapComponentState, onBaseMapAction: (BaseMapAction) -> Unit, onJobComponentAction: (JobMapComponentAction) -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { Box(modifier = modifier.fillMaxSize()) { if (shouldShowMapActions) { @@ -85,7 +87,11 @@ fun HomeScreenMapContainerScreen( ) } - JobMapComponent(state = jobComponentState, onAction = onJobComponentAction) + JobMapComponent( + state = jobComponentState, + onJobComponentAction = onJobComponentAction, + onLoiReportAction = onLoiReportAction, + ) } } } @@ -154,6 +160,7 @@ private fun HomeScreenMapContainerScreenPreview() { shouldShowRecenter = true, onBaseMapAction = {}, onJobComponentAction = {}, + onLoiReportAction = {}, ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt new file mode 100644 index 0000000000..a8fc15720f --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt @@ -0,0 +1,38 @@ +/* + * 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 + +import androidx.annotation.StringRes +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData +import org.groundplatform.domain.model.Survey + +/** + * One-off events emitted by [HomeScreenMapContainerViewModel] for the host fragment to render + * (popups, navigation, dialogs). + */ +sealed interface HomeScreenMapContainerUiEffect { + data class ShowError(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class ShowInfo(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class NavigateToDataCollection(val data: DataCollectionEntryPointData) : + HomeScreenMapContainerUiEffect + + data class ShowDataSharingTerms( + val data: DataCollectionEntryPointData, + val terms: Survey.DataSharingTerms, + ) : HomeScreenMapContainerUiEffect +} 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 32250a83df..c78ce13b7d 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 @@ -22,6 +22,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.groundplatform.android.R import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.system.LocationManager @@ -46,6 +49,7 @@ 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.DataCollectionEntryPointData import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.Feature @@ -62,6 +66,9 @@ import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction +import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @SharedViewModel @@ -81,6 +88,7 @@ internal constructor( private val localValueStore: LocalValueStore, private val locationOfInterestHelper: LocationOfInterestHelper, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : BaseMapViewModel( locationManager, @@ -139,6 +147,9 @@ internal constructor( */ val jobMapComponentState: StateFlow + private val _uiEffects = Channel(Channel.BUFFERED) + val uiEffects: Flow = _uiEffects.receiveAsFlow() + init { // THIS SHOULD NOT BE CALLED ON CONFIG CHANGE @@ -328,4 +339,78 @@ internal constructor( featureClicked.value = null } } + + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = + (jobMapComponentState.value as? JobMapComponentState.LoiSelected)?.loi?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + } + } + + /** Invoked when user clicks on the map cards to collect data. */ + fun onCollectData(cardUiData: DataCollectionEntryPointData) { + viewModelScope.launch { + when { + !cardUiData.canCollectData -> + // Skip data collection screen if the user can't submit any data. + // TODO: Revisit UX for displaying view only mode + // Issue URL: https://github.com/google/ground-android/issues/1667 + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error) + ) + !hasValidTasks(cardUiData) -> + // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + else -> + getDataSharingTerms() + .onSuccess { terms -> + if (terms == null) { + // Data sharing terms already accepted or missing. + _uiEffects.send(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardUiData)) + } else { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardUiData, terms) + ) + } + } + .onFailure { + Timber.e(it, "Failed to get data sharing terms") + val messageId = + if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { + R.string.invalid_data_sharing_terms + } else { + R.string.something_went_wrong + } + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(messageId)) + } + } + } + } + + private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = + when (cardUiData) { + // LOI tasks are filtered out of the tasks list for pre-defined tasks. + is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 + is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() + } + + /** + * Displays a popup hint informing users how to begin collecting data. + * + * This method should only be called after view creation and should only trigger once per view + * create. + */ + fun showDataCollectionHint() { + viewModelScope.launch { + val properties = surveyUpdateFlow.first() + if (properties.noLois && !properties.addLoiPermitted) { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint) + ) + } + } + } } 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 5a73e513f6..e7ae41816b 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 @@ -42,34 +42,43 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnJobSelected import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.theme.AppTheme @Composable -fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit) { +fun JobMapComponent( + state: JobMapComponentState, + onJobComponentAction: (JobMapComponentAction) -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { when (state) { is JobMapComponentState.LoiSelected -> { var showShareLoiModal by rememberSaveable { mutableStateOf(false) } LoiJobSheet( state = state.loi, - onCollectClicked = { onAction(OnAddDataClicked(state.loi)) }, - onDeleteClicked = { onAction(OnDeleteSiteClicked(state.loi)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobCardDismissed) }, + onCollectClicked = { onJobComponentAction(OnAddDataClicked(state.loi)) }, + onDeleteClicked = { onJobComponentAction(OnDeleteSiteClicked(state.loi)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobCardDismissed) }, onShareClicked = { showShareLoiModal = true }, ) if (showShareLoiModal && state.loi.loiReport != null) { - ShareLocationModal(state.loi.loiReport) { showShareLoiModal = false } + ShareLocationModal( + loiReport = state.loi.loiReport, + onLoiReportAction = onLoiReportAction, + onDismiss = { showShareLoiModal = false }, + ) } } is JobMapComponentState.AddLoiButton -> { - AddLoiButton(onClick = { onAction(JobMapComponentAction.OnAddLoiButtonClicked) }) + AddLoiButton(onClick = { onJobComponentAction(JobMapComponentAction.OnAddLoiButtonClicked) }) } is JobMapComponentState.JobSelectionModal -> { JobSelectionModal( jobs = state.jobs.map { it.job }, - onJobClicked = { job -> onAction(OnJobSelected(job)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, + onJobClicked = { job -> onJobComponentAction(OnJobSelected(job)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, ) } is JobMapComponentState.Hidden -> {} @@ -136,7 +145,9 @@ private fun JobMapComponentPreview() { ), ) ) - ) - ) {} + ), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } } 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 index 346a83f0ef..54d17ab5c4 100644 --- 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 @@ -46,11 +46,13 @@ import androidx.compose.ui.window.DialogProperties import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date +import kotlin.time.Clock 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.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme @@ -58,7 +60,11 @@ import org.jetbrains.compose.resources.stringResource as multiplatformStringReso @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { +fun ShareLocationModal( + loiReport: LoiReport, + onDismiss: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current Dialog( @@ -95,25 +101,19 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } TextButton( - modifier = Modifier.align(Alignment.End).padding(top = 16.dp), + modifier = Modifier.align(Alignment.End).padding(16.dp), onClick = onDismiss, ) { Text(text = stringResource(R.string.close)) @@ -143,12 +143,19 @@ private fun ShareLocationModalPreview() { ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) AppTheme { Surface(modifier = Modifier.fillMaxSize()) { - ShareLocationModal(loiReport = testLoiReport, onDismiss = {}) + ShareLocationModal(loiReport = testLoiReport, onDismiss = {}, onLoiReportAction = {}) } } } diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 986cdd2be7..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -129,14 +129,7 @@ object FakeData { ), ) ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = SURVEY.title, - userName = USER.displayName, - userEmail = USER.email, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, - submissions = null, - ), + submissionDetails = null, ) val LOCATION_OF_INTEREST_FEATURE = Feature( diff --git a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt index 7ae38281f4..acaa42caa9 100644 --- a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt +++ b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import org.groundplatform.android.di.coroutines.CoroutineDispatchersModule +import org.groundplatform.android.di.coroutines.DefaultDispatcher import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.android.di.coroutines.MainDispatcher @@ -43,4 +44,8 @@ object TestCoroutineDispatchersModule { @MainDispatcher @Provides fun provideMainDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher } diff --git a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt index cc26d06a15..65377363fe 100644 --- a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt @@ -27,6 +27,7 @@ import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.SubmissionMutation import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission import org.groundplatform.domain.model.submission.TextTaskData import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.model.task.Task @@ -216,6 +217,21 @@ class SubmissionRepositoryTest { assertThat(repository.getPendingCreateCount(loi.id)).isEqualTo(7) } + @Test + fun `getSubmissions returns submissions for the LOI's job from the local store`() = runTest { + val expected = listOf(TEST_SUBMISSION) + whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(expected) + + assertThat(repository.getSubmissions(TEST_LOI)).isEqualTo(expected) + } + + @Test + fun `getSubmissions returns empty list when local store has no submissions`() = runTest { + whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(emptyList()) + + assertThat(repository.getSubmissions(TEST_LOI)).isEmpty() + } + private suspend fun setupMocks( uuid: String = TEST_UUID, loi: LocationOfInterest? = TEST_LOI, @@ -263,5 +279,13 @@ class SubmissionRepositoryTest { deltas = TEST_DELTAS, currentTaskId = TEST_CURRENT_TASK_ID, ) + val TEST_SUBMISSION: Submission = + FakeDataGenerator.newSubmission( + surveyId = TEST_SURVEY.id, + locationOfInterest = TEST_LOI, + job = TEST_JOB, + created = AuditInfo(TEST_USER), + lastModified = AuditInfo(TEST_USER), + ) } } 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 4c252f6e91..593984f57f 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 @@ -27,6 +27,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules import javax.inject.Inject import kotlin.time.Clock import kotlinx.collections.immutable.persistentListOf @@ -41,6 +42,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.di.PdfModule import org.groundplatform.android.getString import org.groundplatform.android.testrules.FragmentScenarioRule import org.groundplatform.android.ui.datacollection.tasks.point.DropPinTaskViewModel @@ -66,16 +68,21 @@ import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterfac import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.shadows.ShadowToast @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest +@UninstallModules(PdfModule::class) @RunWith(RobolectricTestRunner::class) class DataCollectionFragmentTest : BaseHiltTest() { @get:Rule(order = 4) val composeTestRule = createComposeRule() @@ -89,6 +96,7 @@ class DataCollectionFragmentTest : BaseHiltTest() { @Inject lateinit var userRepository: UserRepositoryInterface @BindValue @Mock lateinit var mutationSyncWorkManager: MutationSyncWorkManager + @BindValue @Mock lateinit var loiReportExporter: LoiReportExporter lateinit var fragment: DataCollectionFragment @@ -751,6 +759,48 @@ class DataCollectionFragmentTest : BaseHiltTest() { assertTrue(state is DataCollectionUiState.TaskSubmitted) } + @Test + fun `onLoiReportAction shows an error when exporting the report fails`() = runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.failure(RuntimeException())) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(1) + assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo(getString(R.string.unexpected_error)) + } + + @Test + fun `onLoiReportAction does not show an error when exporting the report succeeds`() = + runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.success(Unit)) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(0) + } + @Test fun `Clicking done after triggering conditional task saves task data`() = runWithTestDispatcher { setupFragment() diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt index 2e93bc5aa4..b0715119ed 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt @@ -75,6 +75,7 @@ class DataCollectionScreenTest { onExitConfirmed = onExitConfirmed, onOpenSettings = {}, onAwaitingPhotoCapture = {}, + onReportExportError = {}, ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt index a0df4f65f0..6e508f80de 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt @@ -120,6 +120,7 @@ class DataCollectionViewModelTest : BaseHiltTest() { viewModelFactory = mock(), dataCollectionInitializer = initializer, getLoiReportUseCase = mock(), + loiReportExporter = mock() ) } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt index 3b558eea13..d50912fc2a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -33,8 +34,10 @@ import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -48,7 +51,11 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Shows the correct content on portrait`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } composeTestRule @@ -71,7 +78,11 @@ class DataSubmissionConfirmationScreenTest { LocalConfiguration provides Configuration().apply { orientation = Configuration.ORIENTATION_LANDSCAPE } ) { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } } @@ -90,7 +101,7 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Does not show QR section if the LoiReport is null`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}) + DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}, onLoiReportAction = {}) } composeTestRule @@ -107,6 +118,7 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismissed = {}, + onLoiReportAction = {}, ) } @@ -119,18 +131,59 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = null), onDismissed = {}, + onLoiReportAction = {}, ) } composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss is triggered when the close button is clicked`() { var dismissed = false composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = { dismissed = true }) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt index 1c26ce5cc1..a14c36e579 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Test import kotlin.test.assertTrue import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.R @@ -34,6 +33,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -176,6 +176,7 @@ class HomeScreenMapContainerScreenTest { jobComponentState = jobComponentState, onBaseMapAction = onBaseMapAction, onJobComponentAction = onJobComponentAction, + onLoiReportAction = {}, ) } } 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 6691549013..8fe2ac7262 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 @@ -27,11 +27,14 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB +import org.groundplatform.android.FakeData.DATA_SHARING_TERMS +import org.groundplatform.android.FakeData.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.R import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.di.LocationOfInterestRepositoryModule import org.groundplatform.android.system.auth.FakeAuthenticationManager @@ -39,11 +42,13 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase +import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -99,7 +104,7 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { loi = LOCATION_OF_INTEREST, submissionCount = 0, showDeleteLoiButton = true, - loiReport = LOCATION_OF_INTEREST_LOI_REPORT, + loiReport = LOCATION_OF_INTEREST_LOI_REPORT.copy(submissionDetails = null), ) ) ) @@ -198,6 +203,108 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { assertThat(result).isNull() } + @Test + fun `onCollectData emits ShowError when user cannot collect data`() = runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = false, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error)) + } + + @Test + fun `onCollectData emits ShowError when card has no valid tasks`() = runWithTestDispatcher { + val cardData = + SelectedLoiSheetData( + canCollectData = true, + loi = LOCATION_OF_INTEREST, + submissionCount = 0, + showDeleteLoiButton = false, + loiReport = null, + ) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + } + + @Test + fun `onCollectData emits ShowDataSharingTerms when terms not yet accepted`() = + runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardData, DATA_SHARING_TERMS) + ) + } + + @Test + fun `onCollectData emits NavigateToDataCollection when terms already accepted`() = + runWithTestDispatcher { + viewModel.grantDataSharingConsent() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardData)) + } + + @Test + fun `onCollectData emits ShowError when data sharing terms are invalid`() = + runWithTestDispatcher { + val survey = + SURVEY.copy( + id = "INVALID_TERMS_SURVEY", + dataSharingTerms = Survey.DataSharingTerms.Custom(""), + ) + remoteDataStore.surveys = listOf(survey) + activateSurvey(survey.id) + advanceUntilIdle() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.invalid_data_sharing_terms)) + } + + @Test + fun `showDataCollectionHint emits ShowInfo for read-only survey with no LOIs`() = + runWithTestDispatcher { + val readOnlySurvey = SURVEY.copy(id = "READ_ONLY_SURVEY", jobMap = mapOf(JOB.id to JOB)) + whenever(loiRepository.getValidLois(readOnlySurvey)).thenReturn(flowOf(setOf())) + remoteDataStore.surveys = listOf(readOnlySurvey) + activateSurvey(readOnlySurvey.id) + advanceUntilIdle() + + viewModel.showDataCollectionHint() + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint)) + } + + @Test + fun `onLoiReportAction emits ShowError when no LOI is selected`() = runWithTestDispatcher { + // No LOI is selected, so there is no report to export. + viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + companion object { private val BOUNDS = Bounds(Coordinates(-20.0, -20.0), Coordinates(-10.0, -10.0)) val CAMERA_POSITION = 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 39c014c2c4..0806fd6037 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 @@ -238,7 +238,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -258,7 +262,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -273,6 +281,12 @@ class JobMapComponentTest { state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit = {}, ) { - composeTestRule.setContent { JobMapComponent(state = state, onAction = { onAction(it) }) } + composeTestRule.setContent { + JobMapComponent( + state = state, + onJobComponentAction = { onAction(it) }, + onLoiReportAction = {}, + ) + } } } 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 index 8ffad04946..c5987d3283 100644 --- 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -30,9 +31,11 @@ import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.groundplatform.ui.theme.AppTheme +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -46,7 +49,9 @@ class ShareLocationModalTest { @Test fun `Modal is displayed correctly and shows the QR code with the LOI geometry`() { composeTestRule.setContent { - AppTheme { ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}) } + AppTheme { + ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}, onLoiReportAction = {}) + } } composeTestRule.onNodeWithText(getString(R.string.share_location)).assertIsDisplayed() composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed() @@ -65,6 +70,7 @@ class ShareLocationModalTest { ShareLocationModal( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismiss = {}, + onLoiReportAction = {}, ) } } @@ -76,19 +82,67 @@ class ShareLocationModalTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissionDetails = null), onDismiss = {}) + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = null), + onDismiss = {}, + onLoiReportAction = {}, + ) } } composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss callback is triggered when close button is clicked`() { var dismissed = false composeTestRule.setContent { - ShareLocationModal(loiReport = LOI_REPORT, onDismiss = { dismissed = true }) + ShareLocationModal( + loiReport = LOI_REPORT, + onDismiss = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() 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 f558df72f5..1769ed44c2 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 @@ -29,6 +29,6 @@ data class LoiReport( val userName: String, val userEmail: String, val dateMillis: Long, - val submissions: List?, + val submissions: List, ) } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt index 222a581415..a76c82812d 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt @@ -36,8 +36,9 @@ data class SubmissionData(private val data: Map = mapOf()) { fun copyWithDeltas(deltas: List): SubmissionData { val newData = data.toMutableMap() deltas.forEach { - if (it.newTaskData.isNotNullOrEmpty()) { - newData[it.taskId] = it.newTaskData + val newTaskData = it.newTaskData + if (newTaskData is SkippedTaskData || newTaskData.isNotNullOrEmpty()) { + newData[it.taskId] = newTaskData } else { newData.remove(it.taskId) } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt index 0b426d9749..7b198accb6 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt @@ -55,4 +55,10 @@ interface SubmissionRepositoryInterface { suspend fun getTotalSubmissionCount(loi: LocationOfInterest): Int suspend fun getPendingCreateCount(loiId: String): Int + + /** + * Returns all submissions recorded for the given LOI. Includes synced submissions and locally + * pending CREATE mutations that have not yet been uploaded. + */ + suspend fun getSubmissions(loi: LocationOfInterest): List } 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 0568c02072..b3fe692e3d 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 @@ -31,6 +31,7 @@ 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 +import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.util.toFixedDecimals @@ -44,37 +45,42 @@ class GetLoiReportUseCase( private val locationOfInterestRepository: LocationOfInterestRepositoryInterface, private val userRepositoryInterface: UserRepositoryInterface, private val surveyRepositoryInterface: SurveyRepositoryInterface, + private val submissionRepositoryInterface: SubmissionRepositoryInterface, ) { /** * Returns a [LoiReport] for the given LOI, or `null` if it does not exist. * - * @param loiName the identifier of the location of interest. + * @param loiName the name of the location of interest + * @param loiId the identifier of the location of interest. * @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(loiName: String, loiId: String, surveyId: String): LoiReport? { - val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) - val user = userRepositoryInterface.getAuthenticatedUser() - val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() - val submissions = null // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 - return loi?.let { - LoiReport( - loiName = loiName, - geoJson = - it.geometry.toGeoJson( - it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } - ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = surveyName, - userName = user.displayName, - userEmail = user.email, - dateMillis = loi.lastModified.clientTimestamp, - submissions = submissions, - ), - ) - } + val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null + val submissions = + submissionRepositoryInterface.getSubmissions(loi).sortedByDescending { + it.lastModified.clientTimestamp + } + val submissionDetails = + if (submissions.isNotEmpty()) { + val user = userRepositoryInterface.getAuthenticatedUser() + val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = user.displayName, + userEmail = user.email, + dateMillis = loi.lastModified.clientTimestamp, + submissions = submissions, + ) + } else null + return LoiReport( + loiName = loiName, + geoJson = + loi.geometry.toGeoJson( + loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } + ), + submissionDetails = submissionDetails, + ) } /** 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 95a316eed7..b922d4b79d 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 @@ -18,6 +18,7 @@ package org.groundplatform.domain.usecases import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.groundplatform.domain.model.geometry.Coordinates @@ -33,6 +34,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.locationofinterest.generateProperties import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository +import org.groundplatform.testing.FakeSubmissionRepository import org.groundplatform.testing.FakeSurveyRepository import org.groundplatform.testing.FakeUserRepository @@ -41,8 +43,9 @@ class GetLoiReportUseCaseTest { private val loiRepository = FakeLocationOfInterestRepository() private val userRepository = FakeUserRepository() private val surveyRepository = FakeSurveyRepository() + private val submissionRepository = FakeSubmissionRepository() private val getLoiReportUseCase = - GetLoiReportUseCase(loiRepository, userRepository, surveyRepository) + GetLoiReportUseCase(loiRepository, userRepository, surveyRepository, submissionRepository) @Test fun `Should get a report with the correct geoJson for a Point`() = runTest { @@ -310,6 +313,7 @@ class GetLoiReportUseCaseTest { @Test fun `Should populate loiName, userName and dateMillis from the inputs`() = runTest { userRepository.currentUser = FakeDataGenerator.newUser(displayName = "John Doe") + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) loiRepository.offlineLoi = loiRepository.offlineLoi.copy( lastModified = AuditInfo(user = userRepository.currentUser, clientTimestamp = 987654321L) @@ -327,6 +331,7 @@ class GetLoiReportUseCaseTest { fun `Should populate surveyName from the offline survey`() = runTest { surveyRepository.offlineSurveys = listOf(FakeDataGenerator.newSurvey(id = "surveyId", title = "Restoration areas")) + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! @@ -334,6 +339,45 @@ class GetLoiReportUseCaseTest { assertEquals("Restoration areas", loiReport.submissionDetails!!.surveyName) } + @Test + fun `Should include all submissions sorted by lastModified clientTimestamp descending`() = + runTest { + val older = + FakeDataGenerator.newSubmission( + id = "older", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 100L), + ) + val middle = + FakeDataGenerator.newSubmission( + id = "middle", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 200L), + ) + val newer = + FakeDataGenerator.newSubmission( + id = "newer", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 300L), + ) + submissionRepository.submissions = listOf(newer, older, middle) + + val loiReport = + getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! + + assertEquals( + listOf("newer", "middle", "older"), + loiReport.submissionDetails!!.submissions.map { it.id }, + ) + } + + @Test + fun `Should return null submission details when no submissions exist`() = runTest { + submissionRepository.submissions = emptyList() + + val loiReport = + getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! + + assertNull(loiReport.submissionDetails) + } + private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { loiRepository.offlineLoi = loiRepository.offlineLoi.copy(geometry = geometry, properties = properties) diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt index 8cfdcc4cac..399ae61171 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt @@ -18,6 +18,7 @@ package org.groundplatform.testing import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.repository.SubmissionRepositoryInterface @@ -26,6 +27,7 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface { var latestDraftSubmissionId: String = "" var pendingCreateCount: Int = 0 var pendingDeleteCount: Int = 0 + var submissions: List = emptyList() var onSaveSubmissionCall = FakeCall {} override suspend fun saveSubmission( @@ -75,6 +77,8 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface { override suspend fun getPendingCreateCount(loiId: String): Int = pendingCreateCount + override suspend fun getSubmissions(loi: LocationOfInterest): List = submissions + data class SaveSubmissionParams( val surveyId: String, val loiId: String, diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt new file mode 100644 index 0000000000..208ca81341 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt @@ -0,0 +1,22 @@ +/* + * 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.ui.components.loireport + +sealed interface LoiReportAction { + data object OnShareClicked : LoiReportAction + + data object OnPdfItemClicked : LoiReportAction +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt new file mode 100644 index 0000000000..005ed0327c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt @@ -0,0 +1,38 @@ +/* + * 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.feature.pdf + +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporter( + private val mapper: LoiReportMapper, + private val exportService: PdfExportService, +) { + suspend fun export(loiReport: LoiReport, action: LoiReportAction): Result = runCatching { + val pdfAction = + when (action) { + LoiReportAction.OnShareClicked -> PdfExportService.Action.Share + LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open + } + + val submission = + loiReport.submissionDetails?.submissions?.firstOrNull() ?: error("No submission to export") + val request = mapper.map(loiReport, submission) ?: error("Failed to map LoiReport") + exportService.export(request, pdfAction) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt new file mode 100644 index 0000000000..cce188c3c0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt @@ -0,0 +1,89 @@ +/* + * 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.feature.pdf + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakePdfExportService +import org.groundplatform.feature.pdf.helpers.FakeStringResolver +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporterTest { + + private val mapper = + LoiReportMapper( + taskValueMapper = + TaskValueMapper(strings = FakeStringResolver, dateFormatter = FakeDateFormatter), + strings = FakeStringResolver, + dateFormatter = FakeDateFormatter, + ) + + @Test + fun `opens the report when the action is OnPdfItemClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.openedPath) + assertNull(service.sharedPath) + } + + @Test + fun `shares the report when the action is OnShareClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnShareClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.sharedPath) + assertNull(service.openedPath) + } + + @Test + fun `returns failure without exporting when report has no submission`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + val loiReport = + FakeDataGenerator.newLoiReport( + submissionDetails = FakeDataGenerator.newSubmissionDetails(submissions = emptyList()) + ) + + val result = exporter.export(loiReport, LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + assertNull(service.openedPath) + } + + @Test + fun `returns failure when export throws`() = runTest { + val service = FakePdfExportService().apply { renderError = RuntimeException("boom") } + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt new file mode 100644 index 0000000000..e8e7614116 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt @@ -0,0 +1,83 @@ +/* + * 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.feature.pdf.helpers + +import kotlinx.coroutines.Dispatchers +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +@Suppress("UseDataClass") +class FakePdfExportService(val outputPath: String = "/tmp/report.pdf") { + var renderError: Throwable? = null + + var openedPath: String? = null + private set + + var sharedPath: String? = null + private set + + var imagesReleased: Boolean = false + private set + + val deletedPaths: MutableList = mutableListOf() + + val service: PdfExportService = + PdfExportService( + imageProvider = + object : PdfImageProvider { + override suspend fun load(qrContent: String?, photoFilenames: Set) = + PdfImageSet(images = emptyMap(), onRelease = { imagesReleased = true }) + }, + renderer = + object : PdfRenderer { + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + renderError?.let { throw it } + } + }, + outputProvider = + object : PdfOutputProvider { + override fun newFilePath(name: String) = outputPath + + override fun exists(name: String) = false + + override fun listFiles() = emptyList() + + override fun deleteReport(path: String) { + deletedPaths.add(path) + } + }, + launcher = + object : PdfReportLauncher { + override fun share(path: String) { + sharedPath = path + } + + override fun open(path: String) { + openedPath = path + } + }, + coroutineDispatcher = Dispatchers.Unconfined, + ) +}