From fd512bd998ef18a61021eb366514ae9a308e18f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 15 May 2026 11:34:58 +0200 Subject: [PATCH 01/17] replace hardcoded contentDescription on Share button --- .../kotlin/org/groundplatform/ui/components/ShareButton.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt index cb4984bac1..e23003ccbc 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt @@ -36,7 +36,7 @@ fun ShareButton(modifier: Modifier = Modifier, onClick: () -> Unit) { Icon( modifier = Modifier.padding(end = 8.dp), imageVector = vectorResource(Res.drawable.ic_share), - contentDescription = "Share", + contentDescription = stringResource(Res.string.share), ) Text(stringResource(Res.string.share), modifier = Modifier.padding(4.dp)) } From 5526bfc421e2438d045e58cf8b6e6a9cb3ef65de Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 15 May 2026 12:17:31 +0200 Subject: [PATCH 02/17] implement logic to fetch LOI submissions and add them to LoiReport --- .../android/di/UseCaseModule.kt | 9 ++++- .../repository/SubmissionRepository.kt | 3 ++ .../repository/SubmissionRepositoryTest.kt | 24 ++++++++++++ .../SubmissionRepositoryInterface.kt | 6 +++ .../domain/usecases/GetLoiReportUseCase.kt | 35 +++++++++-------- .../usecases/GetLoiReportUseCaseTest.kt | 39 ++++++++++++++++++- .../testing/FakeDataGenerator.kt | 21 ++++++++++ .../testing/FakeSubmissionRepository.kt | 4 ++ 8 files changed, 122 insertions(+), 19 deletions(-) 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/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/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/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 a799d68a7d..b243771fd5 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 @@ -33,6 +33,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 @@ -45,33 +46,33 @@ 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 loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null 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( - surveyName = surveyName, - loiName = loiName, - userName = user.displayName, - dateMillis = it.lastModified.clientTimestamp, - geoJson = - it.geometry.toGeoJson( - it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } - ), - submissions = submissions, - ) - } + val submissions = + submissionRepositoryInterface.getSubmissions(loi).sortedBy { it.lastModified.clientTimestamp } + return LoiReport( + surveyName = surveyName, + loiName = loiName, + userName = user.displayName, + dateMillis = loi.lastModified.clientTimestamp, + geoJson = + loi.geometry.toGeoJson( + loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } + ), + submissions = submissions, + ) } /** 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 d802241110..27f47dbe6a 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 @@ -33,6 +33,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 +42,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 { @@ -334,6 +336,41 @@ class GetLoiReportUseCaseTest { assertEquals("Restoration areas", loiReport.surveyName) } + @Test + fun `Should return submissions ordered by lastModified clientTimestamp`() = 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("older", "middle", "newer"), loiReport.submissions?.map { it.id }) + } + + @Test + fun `Should return an empty submissions list when no submissions exist`() = runTest { + submissionRepository.submissions = emptyList() + + val loiReport = + getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! + + assertEquals(emptyList(), loiReport.submissions) + } + 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/FakeDataGenerator.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt index 44af15f04e..5ac3fc3710 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt @@ -34,6 +34,8 @@ import org.groundplatform.domain.model.mutation.SubmissionMutation import org.groundplatform.domain.model.settings.MeasurementUnits import org.groundplatform.domain.model.settings.UserSettings import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission +import org.groundplatform.domain.model.submission.SubmissionData import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.model.task.Condition import org.groundplatform.domain.model.task.MultipleChoice @@ -132,6 +134,25 @@ object FakeDataGenerator { currentTaskId = currentTaskId, ) + fun newSubmission( + id: String = "submission id", + surveyId: String = "survey id", + locationOfInterest: LocationOfInterest = newLocationOfInterest(), + job: Job = newJob(), + created: AuditInfo = AuditInfo(newUser()), + lastModified: AuditInfo = AuditInfo(newUser()), + data: SubmissionData = SubmissionData(), + ): Submission = + Submission( + id = id, + surveyId = surveyId, + locationOfInterest = locationOfInterest, + job = job, + created = created, + lastModified = lastModified, + data = data, + ) + fun newTask( id: String = "taskId", type: Task.Type = Task.Type.TEXT, 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, From aae7afe413d2b371c99fac95181f6b29327d2acd Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 15 May 2026 12:53:54 +0200 Subject: [PATCH 03/17] add action interface to propagate user interactions with the loi report --- .../datacollection/DataCollectionFragment.kt | 15 ++++++- .../ui/datacollection/DataCollectionScreen.kt | 15 ++++++- .../DataCollectionScreenPreviews.kt | 11 ++++- .../DataSubmissionConfirmationScreen.kt | 40 ++++++++++++++----- .../HomeScreenMapContainerFragment.kt | 14 +++++++ .../HomeScreenMapContainerScreen.kt | 9 ++++- .../home/mapcontainer/jobs/JobMapComponent.kt | 19 +++++++-- .../mapcontainer/jobs/ShareLocationModal.kt | 17 ++++---- core/ui/build.gradle.kts | 1 + .../components/loireport/LoiReportAction.kt | 23 +++++++++++ 10 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt 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 adfc69de01..9c76cb9c98 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 @@ -22,6 +22,7 @@ import android.view.ViewGroup import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.groundplatform.android.R import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener @@ -29,7 +30,8 @@ import org.groundplatform.android.ui.common.EphemeralPopups import org.groundplatform.android.ui.home.HomeScreenViewModel import org.groundplatform.android.util.createComposeView import org.groundplatform.android.util.openAppSettings -import javax.inject.Inject +import org.groundplatform.android.util.shareLoiReportPdf +import org.groundplatform.ui.components.loireport.LoiReportAction /** Fragment allowing the user to collect data to complete a task. */ @AndroidEntryPoint @@ -55,6 +57,17 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, + onLoiReportAction = { loiReportAction -> + when (loiReportAction) { + is LoiReportAction.OnShareClicked -> + { + /* TODO */ + } + is LoiReportAction.OnPdfItemClicked -> { + /* TODO */ + } + } + }, ) } 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..829d4835a2 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, + onLoiReportAction: (LoiReportAction) -> Unit, onExitConfirmed: () -> Unit, onOpenSettings: () -> Unit, onAwaitingPhotoCapture: (Boolean) -> Unit, @@ -75,7 +77,11 @@ fun DataCollectionScreen( } } - DataCollectionContent(uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }) { + DataCollectionContent( + uiState = uiState, + onCloseClicked = { viewModel.onCloseClicked() }, + onLoiReportAction = onLoiReportAction, + ) { readyState -> val tasks = readyState.tasks if (tasks.isNotEmpty()) { @@ -134,6 +140,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 +160,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 e338cd6bc2..fa37082f1d 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 @@ -38,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)) } @@ -58,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)) @@ -83,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)) @@ -106,10 +112,11 @@ private fun DataCollectionContentCompletePreview() { dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Point A", geoJson = JsonObject(mapOf()), - submissions = emptyList() + 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/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt index 2c02880433..ad09923b6e 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 @@ -57,6 +57,7 @@ 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 @@ -66,6 +67,7 @@ fun DataSubmissionConfirmationScreen( modifier: Modifier = Modifier, loiReport: LoiReport? = null, onDismissed: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { val baseModifier = modifier @@ -86,12 +88,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), @@ -134,7 +140,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 { @@ -168,12 +178,8 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport loiName = loiReport.loiName, userName = loiReport.userName, date = DateFormat.getDateFormat(context).format(Date(loiReport.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 */ - }, + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, ) } } @@ -207,12 +213,24 @@ private val testLoiReport = @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..c9372746c4 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 @@ -42,8 +42,10 @@ 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.android.util.shareLoiReportPdf import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY +import org.groundplatform.ui.components.loireport.LoiReportAction import timber.log.Timber /** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */ @@ -160,6 +162,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { onJobComponentAction = { handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) }, + onLoiReportAction = { handleLoiReportAction(it) }, ) } } @@ -210,6 +213,17 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } + private fun handleLoiReportAction(action: LoiReportAction) { + when (action) { + is LoiReportAction.OnShareClicked -> { + /* TODO */ + } + is LoiReportAction.OnPdfItemClicked -> { + /* TODO() */ + } + } + } + /** * Displays a popup hint informing users how to begin collecting data. * 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..b3bd0d30d2 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, + onAction = 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/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt index 5a73e513f6..9c182c79be 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,10 +42,15 @@ 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, + onAction: (JobMapComponentAction) -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { when (state) { is JobMapComponentState.LoiSelected -> { var showShareLoiModal by rememberSaveable { mutableStateOf(false) } @@ -59,7 +64,11 @@ fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentActio ) 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 -> { @@ -136,7 +145,9 @@ private fun JobMapComponentPreview() { ), ) ) - ) - ) {} + ), + onAction = {}, + 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 7056b24d96..8ea8709508 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 @@ -50,13 +50,18 @@ 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 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { +fun ShareLocationModal( + loiReport: LoiReport, + onDismiss: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current Dialog( @@ -99,12 +104,8 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { loiName = loiReport.loiName, userName = loiReport.userName, date = DateFormat.getDateFormat(context).format(Date(loiReport.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 */ - }, + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, ) } @@ -147,7 +148,7 @@ private fun ShareLocationModalPreview() { AppTheme { Surface(modifier = Modifier.fillMaxSize()) { - ShareLocationModal(loiReport = testLoiReport, onDismiss = {}) + ShareLocationModal(loiReport = testLoiReport, onDismiss = {}, onLoiReportAction = {}) } } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5d5429969e..2fba29fe25 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { sourceSets { commonMain { dependencies { + implementation(project(":core:domain")) implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) 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..8fcc358386 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt @@ -0,0 +1,23 @@ +/* + * 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 + +import org.groundplatform.domain.model.locationofinterest.LoiReport + +sealed interface LoiReportAction { + data class OnShareClicked(val loiReport: LoiReport) : LoiReportAction + data class OnPdfItemClicked(val loiReport: LoiReport) : LoiReportAction +} From 4a2320f6014a3090c07ab0e1832d9e6df23a2498 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 18 May 2026 16:04:12 +0200 Subject: [PATCH 04/17] add user email to LoiReport --- .../android/ui/datacollection/DataCollectionScreenPreviews.kt | 1 + .../ui/datacollection/DataSubmissionConfirmationScreen.kt | 1 + .../android/ui/home/mapcontainer/jobs/ShareLocationModal.kt | 1 + app/src/test/java/org/groundplatform/android/FakeData.kt | 1 + .../ui/datacollection/DataSubmissionConfirmationScreenTest.kt | 1 + .../android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt | 1 + .../android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt | 1 + .../groundplatform/domain/model/locationofinterest/LoiReport.kt | 1 + .../org/groundplatform/domain/usecases/GetLoiReportUseCase.kt | 1 + 9 files changed, 9 insertions(+) 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 fa37082f1d..fdb899756b 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 @@ -109,6 +109,7 @@ private fun DataCollectionContentCompletePreview() { LoiReport( surveyName = "Test Survey", userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Point A", geoJson = JsonObject(mapOf()), 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 ad09923b6e..564bcb5c00 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 @@ -190,6 +190,7 @@ private val testLoiReport = LoiReport( surveyName = "Test Survey", userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Test LOI", geoJson = 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 8ea8709508..72870e2c07 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 @@ -128,6 +128,7 @@ private fun ShareLocationModalPreview() { loiName = "Test LOI", surveyName = "Test Survey", userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = Clock.System.now().toEpochMilliseconds(), geoJson = JsonObject( diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 5e7892b7c8..bc668eec16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -114,6 +114,7 @@ object FakeData { surveyName = SURVEY.title, loiName = "Unnamed point", userName = USER.displayName, + userEmail = USER.email, dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, geoJson = JsonObject( 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 948e2553b0..ad977c41d6 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 @@ -141,6 +141,7 @@ class DataSubmissionConfirmationScreenTest { surveyName = "Test Survey", loiName = "Test LOI", userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = 987654321L, geoJson = JsonObject( 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 57e2fa1254..57d443dd5c 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 @@ -128,6 +128,7 @@ class LoiJobSheetTest { surveyName = "Test Survey", loiName = name, userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = 987654321L, geoJson = JsonObject( 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 a9a7a2206f..185bf2d045 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 @@ -97,6 +97,7 @@ class ShareLocationModalTest { surveyName = "Test Survey", loiName = LOI_NAME, userName = "John Doe", + userEmail = "john.doe@example.com", dateMillis = 987654321L, geoJson = JsonObject( 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 5c43e198e4..387b6a940d 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 @@ -23,6 +23,7 @@ data class LoiReport( val surveyName: String, val loiName: String, val userName: String, + val userEmail: String, val dateMillis: Long, val geoJson: JsonObject, val submissions: 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 b243771fd5..7ae6dde365 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 @@ -66,6 +66,7 @@ class GetLoiReportUseCase( surveyName = surveyName, loiName = loiName, userName = user.displayName, + userEmail = user.email, dateMillis = loi.lastModified.clientTimestamp, geoJson = loi.geometry.toGeoJson( From 1c4822afc6fdc9d995cc7eb1f31790f9e9b53319 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 19 May 2026 17:13:13 +0200 Subject: [PATCH 05/17] add LoiReport SubmissionData to clarify which data can be skipped from fetching if there are no local submissions --- .../DataCollectionScreenPreviews.kt | 7 +------ .../DataSubmissionConfirmationScreen.kt | 21 +++++++++++-------- .../mapcontainer/jobs/ShareLocationModal.kt | 21 +++++++++++-------- .../org/groundplatform/android/FakeData.kt | 6 +----- .../DataSubmissionConfirmationScreenTest.kt | 13 +++++++----- .../home/mapcontainer/jobs/LoiJobSheetTest.kt | 6 +----- .../jobs/ShareLocationModalTest.kt | 13 +++++++----- .../model/locationofinterest/LoiReport.kt | 16 ++++++++------ .../domain/usecases/GetLoiReportUseCase.kt | 20 +++++++++++------- 9 files changed, 66 insertions(+), 57 deletions(-) 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 fdb899756b..a5c15c07b2 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 @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import kotlin.time.Clock import kotlinx.serialization.json.JsonObject import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.job.Job @@ -107,13 +106,9 @@ private fun DataCollectionContentCompletePreview() { DataCollectionUiState.TaskSubmitted( loiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Point A", geoJson = JsonObject(mapOf()), - submissions = emptyList(), + submissionDetails = null, ) ), onCloseClicked = {}, 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 564bcb5c00..6a1ce1ca24 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 @@ -171,13 +171,13 @@ private fun ShareableContent( ) } - loiReport.submissions?.let { + loiReport.submissionDetails?.let { SubmissionPdfItem( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = loiReport.surveyName, + title = it.surveyName, loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)), + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, ) @@ -188,10 +188,6 @@ private fun ShareableContent( private val testLoiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Test LOI", geoJson = JsonObject( @@ -207,7 +203,14 @@ private val testLoiReport = ), ) ), - submissions = emptyList(), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) @Composable 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 72870e2c07..64f030f979 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 @@ -97,13 +97,13 @@ fun ShareLocationModal( ) } - loiReport.submissions?.let { + loiReport.submissionDetails?.let { SubmissionPdfItem( modifier = Modifier.fillMaxWidth(), - title = loiReport.surveyName, + title = it.surveyName, loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)), + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, ) @@ -126,10 +126,6 @@ private fun ShareLocationModalPreview() { val testLoiReport = LoiReport( loiName = "Test LOI", - surveyName = "Test Survey", - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = Clock.System.now().toEpochMilliseconds(), geoJson = JsonObject( mapOf( @@ -144,7 +140,14 @@ private fun ShareLocationModalPreview() { ), ) ), - submissions = emptyList(), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) AppTheme { diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index bc668eec16..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -111,11 +111,7 @@ object FakeData { val LOCATION_OF_INTEREST_LOI_REPORT = LoiReport( - surveyName = SURVEY.title, loiName = "Unnamed point", - userName = USER.displayName, - userEmail = USER.email, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, geoJson = JsonObject( mapOf( @@ -133,7 +129,7 @@ object FakeData { ), ) ), - submissions = null, + submissionDetails = null, ) val LOCATION_OF_INTEREST_FEATURE = Feature( 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 ad977c41d6..785d935de1 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 @@ -138,11 +138,7 @@ class DataSubmissionConfirmationScreenTest { private companion object { private val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = "Test LOI", - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -157,7 +153,14 @@ class DataSubmissionConfirmationScreenTest { ), ) ), - submissions = emptyList(), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = 987654321L, + submissions = emptyList(), + ), ) } } 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 57d443dd5c..bea437a77a 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 @@ -125,11 +125,7 @@ class LoiJobSheetTest { private fun getLoiReport(name: String): LoiReport = LoiReport( - surveyName = "Test Survey", loiName = name, - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -144,7 +140,7 @@ class LoiJobSheetTest { ), ) ), - submissions = null, + submissionDetails = null, ) companion object { 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 185bf2d045..6f5decf696 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 @@ -94,11 +94,7 @@ class ShareLocationModalTest { const val LOI_NAME = "Test Loi" val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = LOI_NAME, - userName = "John Doe", - userEmail = "john.doe@example.com", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -113,7 +109,14 @@ class ShareLocationModalTest { ), ) ), - submissions = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = 987654321L, + submissions = emptyList(), + ), ) } } 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 387b6a940d..0cc256b74f 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 @@ -20,11 +20,15 @@ import org.groundplatform.domain.model.submission.Submission /** Represents the data collected for a specific LOI which can be downloaded and shared. */ data class LoiReport( - val surveyName: String, val loiName: String, - val userName: String, - val userEmail: String, - val dateMillis: Long, val geoJson: JsonObject, - val submissions: List?, -) + val submissionDetails: SubmissionDetails? +) { + data class SubmissionDetails( + val surveyName: String, + val userName: String, + val userEmail: String, + val dateMillis: Long, + val submissions: 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 7ae6dde365..4a5c4bdd27 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 @@ -58,21 +58,27 @@ class GetLoiReportUseCase( */ suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null - val user = userRepositoryInterface.getAuthenticatedUser() - val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() val submissions = submissionRepositoryInterface.getSubmissions(loi).sortedBy { 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( - surveyName = surveyName, loiName = loiName, - userName = user.displayName, - userEmail = user.email, - dateMillis = loi.lastModified.clientTimestamp, geoJson = loi.geometry.toGeoJson( loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } ), - submissions = submissions, + submissionDetails = submissionDetails, ) } From efe2e707d754c2dea11e01beb94d01d189358c0f Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 20 May 2026 10:42:09 +0200 Subject: [PATCH 06/17] remove unneeded parameter from LoiReportAction --- .../ui/datacollection/DataSubmissionConfirmationScreen.kt | 4 ++-- .../ui/home/mapcontainer/jobs/ShareLocationModal.kt | 4 ++-- .../ui/components/loireport/LoiReportAction.kt | 7 +++---- 3 files changed, 7 insertions(+), 8 deletions(-) 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 6a1ce1ca24..e2b9425adc 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 @@ -178,8 +178,8 @@ private fun ShareableContent( loiName = loiReport.loiName, userName = it.userName, date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, - onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, ) } } 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 64f030f979..9c562bb229 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 @@ -104,8 +104,8 @@ fun ShareLocationModal( loiName = loiReport.loiName, userName = it.userName, date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked(loiReport)) }, - onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked(loiReport)) }, + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, ) } 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 index 8fcc358386..208ca81341 100644 --- 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 @@ -15,9 +15,8 @@ */ package org.groundplatform.ui.components.loireport -import org.groundplatform.domain.model.locationofinterest.LoiReport - sealed interface LoiReportAction { - data class OnShareClicked(val loiReport: LoiReport) : LoiReportAction - data class OnPdfItemClicked(val loiReport: LoiReport) : LoiReportAction + data object OnShareClicked : LoiReportAction + + data object OnPdfItemClicked : LoiReportAction } From 55ad9afd116ffe5039bad5a86cf6f75a405fbd4d Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 21 May 2026 11:00:02 +0200 Subject: [PATCH 07/17] move scan_this_qr_to_download_geojson string to core:ui --- .../ui/datacollection/DataSubmissionConfirmationScreen.kt | 5 ++++- .../android/ui/home/mapcontainer/jobs/ShareLocationModal.kt | 5 ++++- app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-lo/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../datacollection/DataSubmissionConfirmationScreenTest.kt | 6 ++++-- .../ui/home/mapcontainer/jobs/ShareLocationModalTest.kt | 4 +++- .../src/commonMain/composeResources/values-es/strings.xml | 1 + .../src/commonMain/composeResources/values-fr/strings.xml | 1 + .../src/commonMain/composeResources/values-lo/strings.xml | 1 + .../src/commonMain/composeResources/values-pt/strings.xml | 1 + .../src/commonMain/composeResources/values-th/strings.xml | 1 + .../src/commonMain/composeResources/values-vi/strings.xml | 1 + core/ui/src/commonMain/composeResources/values/strings.xml | 1 + 18 files changed, 22 insertions(+), 12 deletions(-) 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 e2b9425adc..f337d5d72d 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 @@ -45,6 +45,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -164,7 +167,7 @@ private fun ShareableContent( GroundQrCode( modifier = Modifier.align(Alignment.Center), title = loiReport.loiName, - footer = stringResource(R.string.scan_this_qr_to_download_geojson), + footer = multiplatformStringResource(Res.string.scan_this_qr_to_download_geojson), content = loiReport.geoJson.toString(), contentDescription = "QR code with LOI Geometry", centerLogoPainter = painterResource(R.drawable.ground_logo), 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 9c562bb229..252e9c83a5 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 @@ -38,6 +38,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -90,7 +93,7 @@ fun ShareLocationModal( GroundQrCode( modifier = Modifier.align(Alignment.Center), title = loiReport.loiName, - footer = stringResource(R.string.scan_this_qr_to_download_geojson), + footer = multiplatformStringResource(Res.string.scan_this_qr_to_download_geojson), content = loiReport.geoJson.toString(), contentDescription = "QR code with LOI Geometry", centerLogoPainter = painterResource(R.drawable.ground_logo), diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c81c31665b..7cf9958d2c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -236,7 +236,6 @@ Error al guardar No se pudo guardar la foto capturada. Por favor, inténtalo de nuevo. Compartir ubicación - Escanea este código QR para ver el GeoJSON Unirse a una encuesta Código QR de encuesta no reconocido diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index da119fe15a..54e2dcf3e3 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -217,7 +217,6 @@ Erreur d’enregistrement Impossible d’enregistrer la photo capturée. Veuillez réessayer. Partager l’emplacement - Scannez ce code QR pour afficher le GeoJson Rejoindre une enquête Code QR d’enquête non reconnu diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index e43b2d45db..d7ecd4ff86 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -206,7 +206,6 @@ ຂໍ້ຜິດພາດໃນການບັນທຶກ ບໍ່ສາມາດບັນທຶກຮູບພາບທີ່ຖ່າຍໄດ້. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ. ແບ່ງປັນຕຳແໜ່ງ - ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON ເຂົ້າຮ່ວມແບບສຳຫຼວດ ບໍ່ຮັບຮູ້ລະຫັດ QR ແບບສຳຫຼວດນີ້ diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0366966baf..76d8763389 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -238,7 +238,6 @@ Erro ao salvar Falha ao salvar a foto capturada. Por favor, tente novamente. Partilhar localização - Leia este código QR para visualizar o GeoJson Aderir ao inquérito Código QR de inquérito não reconhecido diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ea37ef3513..dc9a4971b7 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -208,7 +208,6 @@ ข้อผิดพลาดในการบันทึก ไม่สามารถบันทึกรูปภาพที่ถ่ายได้ โปรดลองอีกครั้ง แชร์ตำแหน่ง - สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON เข้าร่วมแบบสำรวจ ไม่รู้จักรหัสคิวอาร์ของแบบสำรวจนี้ diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 8c841f8e84..02292435ae 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -210,7 +210,6 @@ Lỗi lưu Không thể lưu ảnh đã chụp. Vui lòng thử lại. Chia sẻ vị trí - Quét mã QR này để xem GeoJSON Tham gia khảo sát Không nhận diện được mã QR khảo sát diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bc95ed8e0..333d7172da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,7 +234,6 @@ Re-center Share location - Scan this QR code to view the GeoJson Join survey Unrecognized survey QR code 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 785d935de1..0203d0d543 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 @@ -24,6 +24,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText 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 kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -55,7 +57,7 @@ class DataSubmissionConfirmationScreenTest { composeTestRule.onNodeWithText(LOI_REPORT.loiName).assertIsDisplayed() composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed() composeTestRule - .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson)) + .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson)) .performScrollTo() .assertIsDisplayed() composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().assertIsDisplayed() @@ -79,7 +81,7 @@ class DataSubmissionConfirmationScreenTest { composeTestRule.onNodeWithText(LOI_REPORT.loiName).assertIsDisplayed() composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed() composeTestRule - .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson)) + .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson)) .assertIsDisplayed() composeTestRule.onNodeWithText(getString(R.string.close)).assertIsDisplayed() } 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 6f5decf696..938961f5f5 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 @@ -21,6 +21,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText 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 kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -49,7 +51,7 @@ class ShareLocationModalTest { composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed() composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed() composeTestRule - .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson)) + .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson)) .performScrollTo() .assertIsDisplayed() composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().assertIsDisplayed() diff --git a/core/ui/src/commonMain/composeResources/values-es/strings.xml b/core/ui/src/commonMain/composeResources/values-es/strings.xml index e7a4cf2234..f48f5482ef 100644 --- a/core/ui/src/commonMain/composeResources/values-es/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml @@ -20,4 +20,5 @@ Compartir Sitio: %1$s Recolector de datos: %1$s + Escanea este código QR para ver el GeoJSON diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml index a02bb387a4..ea3bd1aa8b 100644 --- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -19,4 +19,5 @@ Partager Site: %1$s Collecteur de données: %1$s + Scannez ce code QR pour afficher le GeoJson diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml index 7cc6cc9c78..31b7138847 100644 --- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml @@ -19,4 +19,5 @@ ແບ່ງປັນ ຈຸດເກັບຂໍ້ມູນ: %1$s ຜູ້ເກັບຂໍ້ມູນ: %1$s + ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml index 3e790c5acf..e102e2edf1 100644 --- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml @@ -20,4 +20,5 @@ Partilhar Local: %1$s Coletor de dados: %1$s + Leia este código QR para visualizar o GeoJson diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml index fb8107eb9e..32617298c7 100644 --- a/core/ui/src/commonMain/composeResources/values-th/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml @@ -19,4 +19,5 @@ แชร์ ไซต์: %1$s ผู้เก็บข้อมูล: %1$s + สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml index dac00c6a08..cd2bacb027 100644 --- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml @@ -19,4 +19,5 @@ Chia sẻ Địa điểm: %1$s Người thu thập dữ liệu: %1$s + Quét mã QR này để xem GeoJSON diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 0176966ea5..233cf1ac18 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -20,4 +20,5 @@ Share Site: %1$s Data collector: %1$s + Scan this QR code to view the GeoJson From 671f877a500e87ca2ab2ab0bb421b688d79f3e42 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 21 May 2026 12:20:53 +0200 Subject: [PATCH 08/17] move other string to core:ui --- .../tasks/multiplechoice/MultipleChoiceItemView.kt | 7 ++++--- app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-lo/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../src/commonMain/composeResources/values-es/strings.xml | 1 + .../src/commonMain/composeResources/values-fr/strings.xml | 1 + .../src/commonMain/composeResources/values-lo/strings.xml | 1 + .../src/commonMain/composeResources/values-pt/strings.xml | 1 + .../src/commonMain/composeResources/values-th/strings.xml | 1 + .../src/commonMain/composeResources/values-vi/strings.xml | 1 + core/ui/src/commonMain/composeResources/values/strings.xml | 2 ++ 15 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt index f65fb84001..288ebf34c8 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt @@ -37,13 +37,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.groundplatform.android.R +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.other import org.groundplatform.android.common.Constants +import org.jetbrains.compose.resources.stringResource import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.task.MultipleChoice import org.groundplatform.domain.model.task.Option @@ -148,7 +149,7 @@ private fun OtherTextField( @Composable private fun MultipleChoiceItem.toTextLabel() = - AnnotatedString(if (isOtherOption) stringResource(id = R.string.other) else option.label) + AnnotatedString(if (isOtherOption) stringResource(Res.string.other) else option.label) @Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) @Composable diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7cf9958d2c..edcc5cabb1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -136,7 +136,6 @@ Encuesta de solo lectura Sin presentaciones Descartar - Otro %d presentación %d presentaciones diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 54e2dcf3e3..ffa0374d55 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -124,7 +124,6 @@ L’enquête est en lecture seule Aucune soumission Rejeter - Autres %d soumission %d soumissions diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index d7ecd4ff86..fdf2d853fe 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -125,7 +125,6 @@ ແບບສຳຫຼວດເປັນແບບເບິ່ງຢ່າງດຽວ ບໍ່ມີການສົ່ງຂໍ້ມູນ ປິດແຈ້ງເຕືອນ - ອື່ນໆ %d ການສົ່ງຂໍ້ມູນ diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 76d8763389..bb84fbc104 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -136,7 +136,6 @@ Inquérito apenas para leitura Nenhuma submissão Ignorar - Outro %d submissão %d submissões diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index dc9a4971b7..f6c3a5d773 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -125,7 +125,6 @@ แบบสำรวจเป็นแบบอ่านอย่างเดียว ไม่มีการส่งข้อมูล ปิดข้อความ - อื่น ๆ การส่งข้อมูล %d รายการ diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 02292435ae..22655c74fc 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -124,7 +124,6 @@ Khảo sát chỉ được phép xem Chưa có bài gửi nào Đóng lại - Khác %d bài gửi diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 333d7172da..df3210ef8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,7 +140,6 @@ Survey is read-only No submissions Dismiss - Other %d submission %d submissions diff --git a/core/ui/src/commonMain/composeResources/values-es/strings.xml b/core/ui/src/commonMain/composeResources/values-es/strings.xml index f48f5482ef..9197c46393 100644 --- a/core/ui/src/commonMain/composeResources/values-es/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml @@ -21,4 +21,5 @@ Sitio: %1$s Recolector de datos: %1$s Escanea este código QR para ver el GeoJSON + Otro diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml index ea3bd1aa8b..d37a6e916c 100644 --- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -20,4 +20,5 @@ Site: %1$s Collecteur de données: %1$s Scannez ce code QR pour afficher le GeoJson + Autres diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml index 31b7138847..3cdf7c8e70 100644 --- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml @@ -20,4 +20,5 @@ ຈຸດເກັບຂໍ້ມູນ: %1$s ຜູ້ເກັບຂໍ້ມູນ: %1$s ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON + ອື່ນໆ diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml index e102e2edf1..bac1f70831 100644 --- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml @@ -21,4 +21,5 @@ Local: %1$s Coletor de dados: %1$s Leia este código QR para visualizar o GeoJson + Outro diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml index 32617298c7..a86ef18d79 100644 --- a/core/ui/src/commonMain/composeResources/values-th/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml @@ -20,4 +20,5 @@ ไซต์: %1$s ผู้เก็บข้อมูล: %1$s สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON + อื่น ๆ diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml index cd2bacb027..fdabc6cb23 100644 --- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml @@ -20,4 +20,5 @@ Địa điểm: %1$s Người thu thập dữ liệu: %1$s Quét mã QR này để xem GeoJSON + Khác diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 233cf1ac18..80551eb97f 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -21,4 +21,6 @@ Site: %1$s Data collector: %1$s Scan this QR code to view the GeoJson + Skipped + Other From 58db8da559a12583dbc066fc0e180edde5d7517a Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 21 May 2026 15:40:53 +0200 Subject: [PATCH 09/17] add SubmissionPdfDocument to represent the document sections --- .../loireport/SubmissionPdfDocument.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt new file mode 100644 index 0000000000..f089b54770 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt @@ -0,0 +1,37 @@ +/* + * 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 + +data class SubmissionPdfDocument(val header: Header, val rows: List) { + + data class Header( + val survey: String, + val job: String, + val site: String, + val dataCollector: String, + val userEmail: String, + val timestamp: String, + val scanCaption: String, + ) + + data class Row(val question: String, val answer: Answer) + + sealed interface Answer { + data class Text(val lines: List) : Answer + + data class Photo(val remoteFilename: String) : Answer + } +} From 419fe545784994c18b487420aa1774cb8be8e90d Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 21 May 2026 17:20:45 +0200 Subject: [PATCH 10/17] add multiplatform DateFormatter --- .../ui/util/DateFormatter.android.kt} | 27 ++++-------- .../groundplatform/ui/util/DateFormatter.kt | 27 ++++++++++++ .../ui/util/DateFormatter.ios.kt | 42 +++++++++++++++++++ 3 files changed, 77 insertions(+), 19 deletions(-) rename core/ui/src/{commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt => androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt} (52%) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt create mode 100644 core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt similarity index 52% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt rename to core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt index f089b54770..2e2d29af68 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/SubmissionPdfDocument.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt @@ -13,25 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.components.loireport +package org.groundplatform.ui.util -data class SubmissionPdfDocument(val header: Header, val rows: List) { +import java.text.DateFormat +import java.util.Date +import java.util.Locale - data class Header( - val survey: String, - val job: String, - val site: String, - val dataCollector: String, - val userEmail: String, - val timestamp: String, - val scanCaption: String, - ) +actual fun formatDate(millis: Long): String = + DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(millis)) - data class Row(val question: String, val answer: Answer) - - sealed interface Answer { - data class Text(val lines: List) : Answer - - data class Photo(val remoteFilename: String) : Answer - } -} +actual fun formatTime(millis: Long): String = + DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(millis)) diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt new file mode 100644 index 0000000000..23900757b8 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt @@ -0,0 +1,27 @@ +/* + * 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.util + +/** + * Locale-aware date/time formatting primitives. Implemented per-platform because locale-aware + * formatting needs `java.text.DateFormat` on Android and `NSDateFormatter` on iOS. + */ + +/** Formats just the date portion of [millis] in the user's locale. */ +expect fun formatDate(millis: Long): String + +/** Formats just the time portion of [millis] in the user's locale (short style, no seconds). */ +expect fun formatTime(millis: Long): String diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt new file mode 100644 index 0000000000..3b8e56f208 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt @@ -0,0 +1,42 @@ +/* + * 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.util + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSDateFormatterMediumStyle +import platform.Foundation.NSDateFormatterNoStyle +import platform.Foundation.NSDateFormatterShortStyle +import platform.Foundation.dateWithTimeIntervalSince1970 + +@OptIn(ExperimentalForeignApi::class) +actual fun formatDate(millis: Long): String = + NSDateFormatter() + .apply { + dateStyle = NSDateFormatterMediumStyle + timeStyle = NSDateFormatterNoStyle + } + .stringFromDate(NSDate.dateWithTimeIntervalSince1970(millis / 1000.0)) + +@OptIn(ExperimentalForeignApi::class) +actual fun formatTime(millis: Long): String = + NSDateFormatter() + .apply { + dateStyle = NSDateFormatterNoStyle + timeStyle = NSDateFormatterShortStyle + } + .stringFromDate(NSDate.dateWithTimeIntervalSince1970(millis / 1000.0)) From f35f2a9a9d27a9a1fa978b6d1b4ae6b824f36557 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 16:27:59 +0200 Subject: [PATCH 11/17] add multiplatform UI model for the pdf structure --- .../groundplatform/domain/model/task/Task.kt | 3 + .../composeResources/values/strings.xml | 10 ++ .../ui/mapper/LoiReportMapper.kt | 106 ++++++++++++++++++ .../ui/model/SubmissionPdfDocument.kt | 54 +++++++++ 4 files changed, 173 insertions(+) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt index 82169d478c..9601ba9bfd 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt @@ -49,4 +49,7 @@ data class Task( CAPTURE_LOCATION, INSTRUCTIONS, } + + fun isOmittedFromDocExport(): Boolean = + type == Type.DROP_PIN || type == Type.DRAW_AREA || type == Type.INSTRUCTIONS } diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 80551eb97f..1f3695a43f 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -23,4 +23,14 @@ Scan this QR code to view the GeoJson Skipped Other + Survey + Job + Data collector + Altitude: %1$sm + Accuracy: %1$sm + N + S + E + W + Submission diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt new file mode 100644 index 0000000000..87667363c9 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt @@ -0,0 +1,106 @@ +/* + * 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.mapper + +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.job +import ground_android.core.ui.generated.resources.pdf_details_data_collector_label +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.submission +import ground_android.core.ui.generated.resources.survey +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.domain.model.submission.Submission +import org.groundplatform.domain.model.task.PhotoTaskData +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.model.SubmissionPdfDocument.Answer +import org.groundplatform.ui.model.SubmissionPdfDocument.Footer +import org.groundplatform.ui.model.SubmissionPdfDocument.Header +import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.ui.model.SubmissionPdfDocument.Row +import org.groundplatform.ui.system.pdf.PdfExportService +import org.groundplatform.ui.util.formatDate +import org.groundplatform.ui.util.formatTime +import org.jetbrains.compose.resources.getString + +object LoiReportMapper { + + suspend fun map(loiReport: LoiReport, submission: Submission): PdfExportService.Request? { + val details = loiReport.submissionDetails ?: return null + val rows = buildRows(submission) + val document = + SubmissionPdfDocument( + header = buildHeader(details, submission), + qrBlock = buildQrBlock(), + footer = buildFooter(details), + table = + SubmissionPdfDocument.Table( + submissionLabel = getString(Res.string.submission), + loiName = loiReport.loiName, + rows = rows, + ), + ) + val fileName = + listOf(loiReport.loiName, details.userName, details.dateMillis.toString()) + .joinToString("_") { it.filter(::isSafeFileChar) } + .trim('_') + + return PdfExportService.Request( + document = document, + qrContent = loiReport.geoJson.toString(), + fileName = fileName, + ) + } + + private suspend fun buildHeader( + details: LoiReport.SubmissionDetails, + submission: Submission, + ): Header = + Header( + surveyLabel = getString(Res.string.survey), + surveyName = details.surveyName, + jobLabel = getString(Res.string.job), + jobName = submission.job.name ?: submission.job.id, + timestamp = "${formatDate(details.dateMillis)} ${formatTime(details.dateMillis)}", + ) + + private suspend fun buildQrBlock(): QrBlock = + QrBlock(scanCaption = getString(Res.string.scan_this_qr_to_download_geojson)) + + private suspend fun buildFooter(details: LoiReport.SubmissionDetails): Footer = + Footer( + dataCollectorLabel = getString(Res.string.pdf_details_data_collector_label), + dataCollectorName = details.userName, + userEmail = details.userEmail, + ) + + private suspend fun buildRows(submission: Submission): List = + submission.job.tasksSorted.mapNotNull { task -> + if (task.isOmittedFromDocExport()) return@mapNotNull null + + val value = submission.data.getValue(task.id) ?: return@mapNotNull null + + Row( + question = task.label, + answer = + when (value) { + is PhotoTaskData -> Answer.Photo(value.remoteFilename) + else -> Answer.Text(TaskValueMapper.map(task, value).split("\n")) + }, + ) + } + + private fun isSafeFileChar(c: Char): Boolean = c.isLetterOrDigit() || c in "_-" +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt new file mode 100644 index 0000000000..2bc0b96325 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt @@ -0,0 +1,54 @@ +/* + * 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.model + +/** + * UI model for a submission PDF. Each property corresponds to a distinct visual section so that + * platform renderers (Android, iOS) can lay them out independently: + */ +data class SubmissionPdfDocument( + val header: Header, + val qrBlock: QrBlock, + val footer: Footer, + val table: Table, +) { + + data class Header( + val surveyLabel: String, + val surveyName: String, + val jobLabel: String, + val jobName: String, + val timestamp: String, + ) + + data class QrBlock(val scanCaption: String) + + data class Table(val submissionLabel: String, val loiName: String, val rows: List) + + data class Row(val question: String, val answer: Answer) + + sealed interface Answer { + data class Text(val lines: List) : Answer + + data class Photo(val remoteFilename: String) : Answer + } + + data class Footer( + val dataCollectorLabel: String, + val dataCollectorName: String, + val userEmail: String, + ) +} From f417a56c8642a5c1e61bf935a26606ee26b8a07e Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:10:53 +0200 Subject: [PATCH 12/17] add logic to provide QR code and photos to PDF report --- core/ui/build.gradle.kts | 11 +- .../qrcode/QrCodeGenerator.android.kt | 2 +- .../pdf/image/AndroidPdfImageProvider.kt | 102 ++++++++++++++++++ .../ui/system/pdf/image/PdfImage.android.kt | 27 +++++ .../ui/components/qrcode/GroundQrCode.kt | 19 +--- .../ui/components/qrcode/QrCodeGenerator.kt | 61 ++++++++++- .../ui/system/pdf/image/PdfImage.kt | 25 +++++ .../ui/system/pdf/image/PdfImageProvider.kt | 24 +++++ .../ui/system/pdf/image/PdfImageSet.kt | 37 +++++++ .../components/qrcode/QrCodeGenerator.ios.kt | 2 +- .../ui/system/pdf/image/PdfImage.kt | 30 ++++++ gradle/libs.versions.toml | 2 + 12 files changed, 320 insertions(+), 22 deletions(-) create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt create mode 100644 core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 2fba29fe25..9bcec44bdc 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -52,12 +52,21 @@ kotlin { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) } } commonTest { dependencies { implementation(libs.kotlin.test) } } - androidMain { dependencies { implementation(libs.google.zxing) } } + androidMain { + dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.google.zxing) + } + } iosMain { dependencies {} } } diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt index d8aaa505eb..c89755a4c8 100644 --- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt @@ -25,7 +25,7 @@ import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val hints = mapOf( EncodeHintType.ERROR_CORRECTION to diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt new file mode 100644 index 0000000000..67aa3cc8c5 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt @@ -0,0 +1,102 @@ +/* + * 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.system.pdf.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.exifinterface.media.ExifInterface +import java.io.File +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap + +/** + * Android implementation of [PdfImageProvider]. + * + * @param context application context used for resource access and file lookups. + * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. + */ +class AndroidPdfImageProvider( + private val context: Context, + @DrawableRes private val logoDrawableRes: Int, +) : PdfImageProvider { + + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet { + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + + qrContent?.let { content -> + generateQrCodeBitmap(content)?.let { bitmap -> + bitmapsToRelease += bitmap + images[PdfImageSet.ImageRef.Qr] = PdfImage(bitmap) + } + } + + photoFilenames + .filter { it.isNotEmpty() } + .forEach { filename -> + loadPhotoBitmap(filename)?.let { bitmap -> + bitmapsToRelease += bitmap + images[PdfImageSet.ImageRef.Photo(filename)] = PdfImage(bitmap) + } + } + + return PdfImageSet(images) { bitmapsToRelease.forEach(Bitmap::recycle) } + } + + private fun generateQrCodeBitmap(content: String): Bitmap? = + runCatching { + generateQrBitmap( + content = content, + logo = + BitmapFactory.decodeResource(context.resources, logoDrawableRes)?.asImageBitmap(), + logoSizeFraction = PDF_LOGO_SIZE_FRACTION, + ) + .asAndroidBitmap() + } + .getOrNull() + + private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { + val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null + val filename = remoteFilename.substringAfterLast('/') + val file = File(rootDir, filename) + if (!file.exists()) return null + val bitmap = + runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull() ?: return null + return applyExifOrientation(file, bitmap) + } + + /** + * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no + * rotation is needed. + */ + private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap { + val degrees = runCatching { ExifInterface(file.absolutePath).rotationDegrees }.getOrDefault(0) + if (degrees == 0) return bitmap + + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + .getOrNull() + ?.also { if (it != bitmap) bitmap.recycle() } ?: bitmap + } +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt new file mode 100644 index 0000000000..e44a3e55e1 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt @@ -0,0 +1,27 @@ +/* + * 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.system.pdf.image + +import android.graphics.Bitmap + +/** Android wraps a [Bitmap]. The renderer reads [bitmap] directly to draw on a Canvas. */ +actual class PdfImage(val bitmap: Bitmap) { + actual val width: Int + get() = bitmap.width + + actual val height: Int + get() = bitmap.height +} 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 23b076a7c8..6b8c22b321 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 @@ -48,23 +48,6 @@ 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. - * - * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on - * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold - * to ensure the QR code remains reliably scannable even with a logo applied. - */ -private const val MAX_QR_BYTES_WITH_LOGO = 1000 - -/** - * The relative size of the center logo as a fraction of the QR code's total rendered size. - * - * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of - * high error correction (ECC level H), which can tolerate approximately 30% data loss. - */ -private const val LOGO_SIZE_FRACTION = 0.15f - /** * Displays a QR code generated from the given [content] string. * @@ -85,7 +68,7 @@ fun GroundQrCode( val qrBitmap by produceState(initialValue = null, key1 = content, key2 = showLogo) { - value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) } + value = withContext(Dispatchers.Default) { encodeQrBitmap(content, showLogo) } } Column( diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt index 529a7e0d19..bc262e9cbe 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt @@ -15,8 +15,67 @@ */ package org.groundplatform.ui.components.qrcode +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize internal const val QR_SIZE_PX = 512 -expect fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap +/** + * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. + * + * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on + * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold + * to ensure the QR code remains reliably scannable even with a logo applied. + */ +const val MAX_QR_BYTES_WITH_LOGO = 1000 + +/** + * Default relative size of the center logo as a fraction of the QR code's total size. + * + * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of + * high error correction (ECC level H), which can tolerate approximately 30% data loss. + */ +const val LOGO_SIZE_FRACTION = 0.15f +const val PDF_LOGO_SIZE_FRACTION = 0.22f + +/** + * Encodes [content] into a bare QR bitmap, using high error correction when [useHighEcc] is set. + */ +expect fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap + +/** + * Generates a QR bitmap for a given [content] with a [logo] in its center. The logo is only applied + * when one is supplied and the content size is below [MAX_QR_BYTES_WITH_LOGO] to keep the code + * scannable. + */ +fun generateQrBitmap( + content: String, + logo: ImageBitmap?, + logoSizeFraction: Float = LOGO_SIZE_FRACTION, +): ImageBitmap = + if (logo == null || content.encodeToByteArray().size > MAX_QR_BYTES_WITH_LOGO) { + encodeQrBitmap(content, useHighEcc = false) + } else encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction) + +/** Draws [logo] centered over the receiver, scaled to [fraction] of its size, into a new bitmap. */ +private fun ImageBitmap.withCenteredLogo(logo: ImageBitmap, fraction: Float): ImageBitmap { + val output = ImageBitmap(width, height) + val canvas = Canvas(output) + val paint = Paint().apply { filterQuality = FilterQuality.High } + canvas.drawImage(this, Offset.Zero, paint) + + val logoWidth = (width * fraction).toInt() + val logoHeight = (height * fraction).toInt() + canvas.drawImageRect( + image = logo, + dstOffset = IntOffset((width - logoWidth) / 2, (height - logoHeight) / 2), + dstSize = IntSize(logoWidth, logoHeight), + paint = paint, + ) + return output +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt new file mode 100644 index 0000000000..0128e85bca --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt @@ -0,0 +1,25 @@ +/* + * 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.system.pdf.image + +/** + * Opaque platform image handle (Android `Bitmap`, iOS `CGImageRef`). The renderer reads the + * platform value via the `actual` declaration. + */ +expect class PdfImage { + val width: Int + val height: Int +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt new file mode 100644 index 0000000000..7327708764 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt @@ -0,0 +1,24 @@ +/* + * 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.system.pdf.image + +/** + * Platform abstraction for images (QR code and photos) needed for PDF rendering. Implementations + * should handle bitmap decoding and resource lifecycle management. + */ +interface PdfImageProvider { + suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt new file mode 100644 index 0000000000..335c559e63 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt @@ -0,0 +1,37 @@ +/* + * 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.system.pdf.image + +/** + * The set of images needed to render a single report. Owns the lifecycle of the platform handles, + * call [release] in a `finally` block to free native resources. + */ +class PdfImageSet( + private val images: Map, + private val onRelease: () -> Unit = {}, +) { + operator fun get(ref: ImageRef): PdfImage? = images[ref] + + fun release() { + onRelease() + } + + sealed interface ImageRef { + data object Qr : ImageRef + + data class Photo(val filename: String) : ImageRef + } +} diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt index 80c2139c79..58ea1067de 100644 --- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt @@ -56,7 +56,7 @@ private const val INPUT_CORRECTION_LEVEL_KEY = "inputCorrectionLevel" private val ciContext: CIContext = CIContext.contextWithOptions(null) -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val ciImage = createQrCIImage(content, useHighEcc) val scaled = scaleToTargetSize(ciImage) return scaled.toComposeImageBitmap() diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt new file mode 100644 index 0000000000..a90c65581c --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt @@ -0,0 +1,30 @@ +/* + * 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.system.pdf.image + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth +import platform.CoreGraphics.CGImageRef + +@OptIn(ExperimentalForeignApi::class) +actual class PdfImage(val cgImage: CGImageRef) { + actual val width: Int + get() = CGImageGetWidth(cgImage).toInt() + + actual val height: Int + get() = CGImageGetHeight(cgImage).toInt() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a815097933..55779f39d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ coreTestingVersion = "1.1.1" coreVersion = "1.7.0" coroutinesVersion = "1.11.0" detektVersion = "1.23.8" +exifInterfaceVersion = "1.4.2" espressoContribVersion = "3.7.0" firebaseBomVersion = "34.13.0" firebaseCrashlyticsGradleVersion = "3.0.7" @@ -90,6 +91,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifInterfaceVersion" } androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoContribVersion" } From 4e7ba1894b9722ff86eba426c36feb81f3f3908f Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:45:34 +0200 Subject: [PATCH 13/17] add formatter for each task --- .../domain/usecases/GetLoiReportUseCase.kt | 12 +-- .../domain/util/NumberFormatter.kt | 31 ++++++ .../ui/mapper/TaskValueMapper.kt | 100 ++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt 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 4a5c4bdd27..493f420520 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,8 +15,6 @@ */ package org.groundplatform.domain.usecases -import kotlin.math.absoluteValue -import kotlin.math.round import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -36,6 +34,7 @@ import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterfac import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.domain.util.toFixedDecimals /** * Use case that generates a [LoiReport] containing the LOI geometry and properties as a GeoJSON. @@ -136,12 +135,8 @@ class GetLoiReportUseCase( /** Renders this [Double] as a JSON number with exactly 6 decimal digits. */ private fun Double.roundTo6Decimals(): JsonPrimitive { - val scaled = round(this * DECIMAL_SCALE).toLong() - val sign = if (scaled < 0) "-" else "" - val absScaled = scaled.absoluteValue - val intPart = absScaled / DECIMAL_SCALE - val fracPart = (absScaled % DECIMAL_SCALE).toString().padStart(DECIMAL_DIGITS, '0') - return JsonUnquotedLiteral("$sign$intPart.$fracPart") + val value = toFixedDecimals(DECIMAL_DIGITS) + return JsonUnquotedLiteral(value) } private companion object { @@ -155,6 +150,5 @@ class GetLoiReportUseCase( const val TYPE_POLYGON = "Polygon" const val TYPE_MULTI_POLYGON = "MultiPolygon" const val DECIMAL_DIGITS = 6 - const val DECIMAL_SCALE = 1_000_000L } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt new file mode 100644 index 0000000000..801fafb6be --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt @@ -0,0 +1,31 @@ +/* + * 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.domain.util + +import kotlin.math.absoluteValue +import kotlin.math.round + +fun Double.toFixedDecimals(decimals: Int): String { + if (decimals <= 0) return round(this).toLong().toString() + var scale = 1L + repeat(decimals) { scale *= 10 } + val scaled = round(this * scale).toLong() + val sign = if (scaled < 0) "-" else "" + val absScaled = scaled.absoluteValue + val intPart = absScaled / scale + val fracPart = (absScaled % scale).toString().padStart(decimals, '0') + return "$sign$intPart.$fracPart" +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt new file mode 100644 index 0000000000..3e9bd1fced --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt @@ -0,0 +1,100 @@ +/* + * 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.mapper + +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.accuracy +import ground_android.core.ui.generated.resources.altitude +import ground_android.core.ui.generated.resources.east +import ground_android.core.ui.generated.resources.north +import ground_android.core.ui.generated.resources.other +import ground_android.core.ui.generated.resources.skipped +import ground_android.core.ui.generated.resources.south +import ground_android.core.ui.generated.resources.west +import kotlin.math.absoluteValue +import kotlin.math.round +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.submission.CaptureLocationTaskData +import org.groundplatform.domain.model.submission.DateTimeTaskData +import org.groundplatform.domain.model.submission.MultipleChoiceTaskData +import org.groundplatform.domain.model.submission.NumberTaskData +import org.groundplatform.domain.model.submission.SkippedTaskData +import org.groundplatform.domain.model.submission.TaskData +import org.groundplatform.domain.model.submission.TextTaskData +import org.groundplatform.domain.model.task.Task +import org.groundplatform.domain.util.toFixedDecimals +import org.groundplatform.ui.util.formatDate +import org.groundplatform.ui.util.formatTime +import org.jetbrains.compose.resources.getString + +object TaskValueMapper { + private const val DEGREES_DECIMALS = 6 + + /** Renders a [TaskData] value as plain text. */ + suspend fun map(task: Task, value: TaskData): String = + when (value) { + is SkippedTaskData -> getString(Res.string.skipped) + is TextTaskData -> value.text + is NumberTaskData -> value.number + is DateTimeTaskData -> formatTaskDateTime(task, value.timeInMillis) + is MultipleChoiceTaskData -> formatMultipleChoice(task, value) + is CaptureLocationTaskData -> formatCaptureLocation(value) + else -> "" + } + + /** Date for DATE tasks, time for TIME, date + time otherwise. */ + private fun formatTaskDateTime(task: Task, millis: Long): String = + when (task.type) { + Task.Type.DATE -> formatDate(millis) + Task.Type.TIME -> formatTime(millis) + else -> "${formatDate(millis)} ${formatTime(millis)}" + } + + private suspend fun formatMultipleChoice(task: Task, value: MultipleChoiceTaskData): String { + val options = task.multipleChoice?.options.orEmpty() + val selectedLabels = + value.getSelectedOptionsIdsExceptOther().map { id -> + options.firstOrNull { it.id == id }?.label ?: id + } + val withOther = + if (value.isOtherTextSelected()) { + selectedLabels + "${getString(Res.string.other)}: ${value.getOtherText()}" + } else { + selectedLabels + } + return withOther.joinToString("; ") + } + + private suspend fun formatCaptureLocation(value: CaptureLocationTaskData): String { + val lines = mutableListOf(formatPoint(value.location)) + value.altitude?.let { lines.add(getString(Res.string.altitude, formatMeters(it))) } + value.accuracy?.let { lines.add(getString(Res.string.accuracy, formatMeters(it))) } + return lines.joinToString("\n") + } + + private suspend fun formatPoint(point: Point): String { + val lat = point.coordinates.lat + val lng = point.coordinates.lng + val latDir = if (lat >= 0) getString(Res.string.north) else getString(Res.string.south) + val lngDir = if (lng >= 0) getString(Res.string.east) else getString(Res.string.west) + return "${formatDegrees(lat)} $latDir, ${formatDegrees(lng)} $lngDir" + } + + private fun formatDegrees(value: Double): String = + "${value.absoluteValue.toFixedDecimals(DEGREES_DECIMALS)}°" + + private fun formatMeters(value: Double): String = round(value).toLong().toString() +} From adc2115c5d5501663ab8b6fc621dbdbe6193c537 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:46:36 +0200 Subject: [PATCH 14/17] add render logic for each pdf element --- .../ui/system/pdf/AndroidPdfRenderer.kt | 48 ++ .../groundplatform/ui/system/pdf/Cursor.kt | 43 ++ .../groundplatform/ui/system/pdf/PdfWriter.kt | 417 ++++++++++++++++++ .../groundplatform/ui/system/pdf/PdfConfig.kt | 42 ++ .../ui/system/pdf/PdfRenderer.kt | 28 ++ 5 files changed, 578 insertions(+) create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt new file mode 100644 index 0000000000..6b8bc38159 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,48 @@ +/* + * 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.system.pdf + +import android.graphics.pdf.PdfDocument +import java.io.File +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.system.pdf.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]. + */ +class AndroidPdfRenderer : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + val pdf = PdfDocument() + try { + with(PdfWriter(pdf, images)) { + setHeader(document.header) + setFooter(document.footer) + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } + File(outputPath).outputStream().use { pdf.writeTo(it) } + } finally { + pdf.close() + } + } +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt new file mode 100644 index 0000000000..6ba4fa69e0 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt @@ -0,0 +1,43 @@ +/* + * 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.system.pdf + +import org.groundplatform.ui.system.pdf.PdfConfig.MARGIN +import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_HEIGHT + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class Cursor(private val pageHeight: Int = PAGE_HEIGHT, private val margin: Int = MARGIN) { + /** Space kept clear above the bottom margin for the footer; set once the footer is known. */ + var footerReserve: Float = 0f + + var y: Float = margin.toFloat() + private set + + fun reset() { + y = margin.toFloat() + } + + fun moveTo(absoluteY: Float) { + y = absoluteY + } + + fun advance(delta: Float) { + y += delta + } + + /** Whether a block of the given [height] still fits above the footer reserve on this page. */ + fun fits(height: Float): Boolean = y + height <= pageHeight - margin - footerReserve +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt new file mode 100644 index 0000000000..ed41a87d4b --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt @@ -0,0 +1,417 @@ +/* + * 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.system.pdf + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.util.SizeF +import androidx.core.graphics.withTranslation +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.model.SubmissionPdfDocument.Answer +import org.groundplatform.ui.model.SubmissionPdfDocument.Footer +import org.groundplatform.ui.model.SubmissionPdfDocument.Header +import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.ui.system.pdf.PdfConfig.BODY_SIZE +import org.groundplatform.ui.system.pdf.PdfConfig.BORDER_WIDTH +import org.groundplatform.ui.system.pdf.PdfConfig.CAPTION_SIZE +import org.groundplatform.ui.system.pdf.PdfConfig.CELL_PADDING +import org.groundplatform.ui.system.pdf.PdfConfig.FOOTER_TOP_GAP +import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_BOTTOM_GAP +import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_COLUMN_GAP +import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_SIZE +import org.groundplatform.ui.system.pdf.PdfConfig.LINE_SPACING +import org.groundplatform.ui.system.pdf.PdfConfig.MARGIN +import org.groundplatform.ui.system.pdf.PdfConfig.MAX_FOOTER_LINES +import org.groundplatform.ui.system.pdf.PdfConfig.MAX_HEADER_VALUE_LINES +import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_HEIGHT +import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_WIDTH +import org.groundplatform.ui.system.pdf.PdfConfig.PHOTO_MAX_HEIGHT +import org.groundplatform.ui.system.pdf.PdfConfig.QR_SIZE +import org.groundplatform.ui.system.pdf.PdfConfig.TABLE_QUESTION_RATIO +import org.groundplatform.ui.system.pdf.image.PdfImage +import org.groundplatform.ui.system.pdf.image.PdfImageSet + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [Cursor], shared paints) shared by all sections. + */ +internal class PdfWriter(private val pdf: PdfDocument, private val images: PdfImageSet) { + private val headerPaint = textPaint(HEADER_SIZE, bold = true) + private val bodyPaint = textPaint(BODY_SIZE, bold = false) + private val captionPaint = textPaint(CAPTION_SIZE, bold = false) + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = BORDER_WIDTH + isAntiAlias = true + } + + /** Bilinear filtering + dithering so down-scaled photos stay smooth. */ + private val photoPaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + private val cursor = Cursor() + private var pageIndex = 0 + private var page: PdfDocument.Page? = null + + /** Top-Y of the table on the current page, or null if no table is in progress on this page. */ + private var tableTopOnPage: Float? = null + + private var header: Header? = null + private var footerLayouts: FooterLayouts? = null + + fun setHeader(header: Header) { + this.header = header + } + + /** Pre-lays out the footer columns so its height is known up front. */ + fun setFooter(footer: Footer) { + val cols = threeColumnLayout() + footerLayouts = + FooterLayouts( + label = staticLayout(footer.dataCollectorLabel, headerPaint, cols.colWidth, maxLines = 1), + name = + staticLayout( + footer.dataCollectorName, + bodyPaint, + cols.colWidth, + Layout.Alignment.ALIGN_CENTER, + MAX_FOOTER_LINES, + ), + email = + staticLayout( + footer.userEmail, + bodyPaint, + cols.colWidth, + Layout.Alignment.ALIGN_OPPOSITE, + MAX_FOOTER_LINES, + ), + leftX = cols.leftX, + centerX = cols.centerX, + rightX = cols.rightX, + ) + cursor.footerReserve = footerLayouts!!.height + FOOTER_TOP_GAP + } + + fun finalizePage() { + flushTableDivider() + drawFooter() + page?.also { pdf.finishPage(it) } + page = null + } + + private fun canvas(): Canvas = (page ?: beginPage()).canvas + + private fun ensurePage() { + if (page == null) beginPage() + } + + private fun beginPage(): PdfDocument.Page { + val info = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, ++pageIndex).create() + return pdf.startPage(info).also { + page = it + cursor.reset() + drawHeader() + } + } + + private fun newPageIfShort(spaceNeeded: Float) { + if (!cursor.fits(spaceNeeded)) { + finalizePage() + beginPage() + } + } + + /** Survey | Job | Timestamp drawn at the top of every page. */ + private fun drawHeader() { + val header = header ?: return + val cols = threeColumnLayout() + val top = cursor.y + val surveyBottom = + drawLabeledColumn(header.surveyLabel, header.surveyName, cols.leftX, cols.colWidth, top) + val jobBottom = + drawLabeledColumn( + header.jobLabel, + header.jobName, + cols.centerX, + cols.colWidth, + top, + Layout.Alignment.ALIGN_CENTER, + ) + val timestampBottom = + drawText( + header.timestamp, + cols.rightX, + top, + cols.colWidth, + bodyPaint, + Layout.Alignment.ALIGN_OPPOSITE, + MAX_HEADER_VALUE_LINES, + ) + cursor.moveTo(maxOf(surveyBottom, jobBottom, timestampBottom) + HEADER_BOTTOM_GAP) + } + + /** Draws a bold [label] above its [value] in one header column; returns the column's bottom Y. */ + private fun drawLabeledColumn( + label: String, + value: String, + x: Float, + width: Int, + top: Float, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + ): Float { + val afterLabel = drawText(label, x, top, width, headerPaint, alignment) + return drawText( + value, + x, + afterLabel + LINE_SPACING, + width, + bodyPaint, + alignment, + MAX_HEADER_VALUE_LINES, + ) + } + + /** QR code + scan caption, right-aligned. */ + fun drawQrBlock(block: QrBlock) { + ensurePage() + val x = (PAGE_WIDTH - MARGIN - QR_SIZE).toFloat() + var y = cursor.y + images[PdfImageSet.ImageRef.Qr]?.let { qr -> + canvas().drawImage(qr, RectF(x, y, x + QR_SIZE, y + QR_SIZE), null) + y += QR_SIZE + } + y += LINE_SPACING + y = drawText(block.scanCaption, x, y, QR_SIZE, captionPaint, Layout.Alignment.ALIGN_CENTER) + cursor.moveTo(y + LINE_SPACING * 2) + } + + /** Data Collector | Name | Email anchored to the bottom of every page. */ + private fun drawFooter() { + val footer = footerLayouts ?: return + val top = PAGE_HEIGHT - MARGIN - footer.height + drawStaticLayout(footer.label, footer.leftX, top) + drawStaticLayout(footer.name, footer.centerX, top) + drawStaticLayout(footer.email, footer.rightX, top) + } + + /** Pre-laid-out footer columns and their X positions, sized once in [setFooter]. */ + private class FooterLayouts( + val label: StaticLayout, + val name: StaticLayout, + val email: StaticLayout, + val leftX: Float, + val centerX: Float, + val rightX: Float, + ) { + val height: Float = maxOf(label.height, name.height, email.height).toFloat() + } + + private fun threeColumnLayout(): ThreeColumns { + val usable = PAGE_WIDTH - 2 * MARGIN + val colWidth = (usable - 2 * HEADER_COLUMN_GAP) / 3 + val leftX = MARGIN.toFloat() + val centerX = leftX + colWidth + HEADER_COLUMN_GAP + val rightX = centerX + colWidth + HEADER_COLUMN_GAP + return ThreeColumns(leftX, centerX, rightX, colWidth) + } + + private data class ThreeColumns( + val leftX: Float, + val centerX: Float, + val rightX: Float, + val colWidth: Int, + ) + + fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + ensurePage() + val width = PAGE_WIDTH - 2 * MARGIN + val x = MARGIN.toFloat() + cursor.advance(LINE_SPACING * 2) + val label = "${table.submissionLabel}: ${table.loiName}" + cursor.moveTo(drawText(label, x, cursor.y, width, headerPaint)) + cursor.advance(LINE_SPACING) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> drawTableRow(row.question, answer.lines.joinToString("\n"), null) + is Answer.Photo -> + drawTableRow( + row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + flushTableDivider() + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, bodyPaint, TABLE.questionTextWidth) + val answerLayout = + if (answerText.isEmpty()) null else staticLayout(answerText, bodyPaint, TABLE.answerTextWidth) + val photoSize = photo?.let { fitInside(it, TABLE.answerTextWidth, PHOTO_MAX_HEIGHT) } + val rowHeight = computeRowHeight(questionLayout, answerLayout, photoSize) + + newPageIfShort(rowHeight) + val left = MARGIN.toFloat() + if (tableTopOnPage == null) { + tableTopOnPage = cursor.y + canvas().drawLine(left, cursor.y, left + TABLE.width, cursor.y, strokePaint) + } + + val top = cursor.y + val midX = left + TABLE.questionColWidth + + drawStaticLayout(questionLayout, left + CELL_PADDING, top + CELL_PADDING) + drawAnswerCell(midX, top, answerLayout, photo, photoSize) + cursor.advance(rowHeight) + + canvas().drawLine(left, cursor.y, left + TABLE.width, cursor.y, strokePaint) + } + + private fun computeRowHeight( + questionLayout: StaticLayout, + answerLayout: StaticLayout?, + photoSize: SizeF?, + ): Float { + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + val photoHeight = photoSize?.height ?: 0f + val photoSpacing = if (photoSize != null && answerLayout != null) LINE_SPACING else 0f + return maxOf(questionLayout.height.toFloat(), answerHeight + photoHeight + photoSpacing) + + 2 * CELL_PADDING + } + + /** Draws the single vertical divider for the current page's table slice and clears the marker. */ + private fun flushTableDivider() { + val top = tableTopOnPage ?: return + val midX = MARGIN + TABLE.questionColWidth.toFloat() + canvas().drawLine(midX, top, midX, cursor.y, strokePaint) + tableTopOnPage = null + } + + private fun drawAnswerCell( + midX: Float, + top: Float, + answerLayout: StaticLayout?, + photo: PdfImage?, + photoSize: SizeF?, + ) { + val cellLeft = midX + CELL_PADDING + var y = top + CELL_PADDING + answerLayout?.let { + drawStaticLayout(it, cellLeft, y) + y += it.height + LINE_SPACING + } + if (photo != null && photoSize != null) { + // Centre horizontally within the answer cell. + val photoX = cellLeft + (TABLE.answerTextWidth - photoSize.width) / 2f + canvas() + .drawImage( + photo, + RectF(photoX, y, photoX + photoSize.width, y + photoSize.height), + photoPaint, + ) + } + } + + /** Lays out [text] and draws it at ([x], [y]); returns the Y just below the drawn text. */ + private fun drawText( + text: String, + x: Float, + y: Float, + maxWidth: Int, + paint: TextPaint, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): Float { + if (text.isEmpty()) return y + val layout = staticLayout(text, paint, maxWidth, alignment, maxLines) + drawStaticLayout(layout, x, y) + return y + layout.height + } + + private fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + /** A text paint at [size] points, [bold] for labels. */ + private fun textPaint(size: Float, bold: Boolean): TextPaint = + TextPaint().apply { + textSize = size + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + + /** + * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a + * single long value can't grow the layout unboundedly. + */ + private fun staticLayout( + text: String, + paint: TextPaint, + maxWidth: Int, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): StaticLayout = + StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth) + .setAlignment(alignment) + .setLineSpacing(LINE_SPACING, 1f) + .apply { + if (maxLines != Int.MAX_VALUE) { + setMaxLines(maxLines) + setEllipsize(TextUtils.TruncateAt.END) + } + } + .build() + + /** + * Scales [image] to fit within [maxWidth] x [maxHeight], keeping aspect ratio, never upscaling. + */ + private fun fitInside(image: PdfImage, maxWidth: Int, maxHeight: Int): SizeF { + val scale = minOf(maxWidth.toFloat() / image.width, maxHeight.toFloat() / image.height, 1f) + return SizeF(image.width * scale, image.height * scale) + } + + /** Draws [image] scaled to fill [dst]. */ + private fun Canvas.drawImage(image: PdfImage, dst: RectF, paint: Paint?) { + drawBitmap(image.bitmap, Rect(0, 0, image.bitmap.width, image.bitmap.height), dst, paint) + } +} + +/** Column widths for the question/answer table. */ +private val TABLE = run { + val tableWidth = PAGE_WIDTH - 2 * MARGIN + val questionColWidth = (tableWidth * TABLE_QUESTION_RATIO).toInt() + object { + val width = tableWidth + val questionColWidth = questionColWidth + val questionTextWidth = questionColWidth - 2 * CELL_PADDING + val answerTextWidth = tableWidth - questionColWidth - 2 * CELL_PADDING + } +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt new file mode 100644 index 0000000000..595eec69aa --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt @@ -0,0 +1,42 @@ +/* + * 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.system.pdf + +/** + * Dimensional and type-scale constants shared by the Android and iOS PDF renderers. Keeping these + * in commonMain prevents the two platforms from drifting on page size, margins, or type scale. + * + * All measurements are in PDF points (1/72 inch); A4 is 595 × 842. + */ +object PdfConfig { + const val PAGE_WIDTH = 595 + const val PAGE_HEIGHT = 842 + const val MARGIN = 40 + const val HEADER_SIZE = 11f + const val BODY_SIZE = 11f + const val CAPTION_SIZE = 9f + const val LINE_SPACING = 4f + const val QR_SIZE = 200 + const val HEADER_COLUMN_GAP = 16 + const val TABLE_QUESTION_RATIO = 0.35f + const val CELL_PADDING = 6 + const val BORDER_WIDTH = 0.5f + const val PHOTO_MAX_HEIGHT = 360 + const val HEADER_BOTTOM_GAP = 28f + const val FOOTER_TOP_GAP = 28f + const val MAX_HEADER_VALUE_LINES = 2 + const val MAX_FOOTER_LINES = 2 +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt new file mode 100644 index 0000000000..952da3152a --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt @@ -0,0 +1,28 @@ +/* + * 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.system.pdf + +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.system.pdf.image.PdfImageSet + +/** + * Rasterises a [SubmissionPdfDocument] to a PDF file. Each platform should use its native text + * layout and PDF APIs to handle wrapping, pagination, and drawing. Writes the result to the + * provided output path. + */ +interface PdfRenderer { + suspend fun render(document: SubmissionPdfDocument, images: PdfImageSet, outputPath: String) +} From 5549b4fe2ee55389a2e7cdb4c676a1c9c7e8319e Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:49:24 +0200 Subject: [PATCH 15/17] add PdfOutputProvider and implementation --- app/src/main/res/xml/file_paths.xml | 3 ++ .../system/pdf/io/AndroidPdfOutputProvider.kt | 43 +++++++++++++++++++ .../ui/system/pdf/io/PdfOutputProvider.kt | 42 ++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index e58780065b..6500c99a78 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -19,4 +19,7 @@ + \ No newline at end of file diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..6d242c6adf --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt @@ -0,0 +1,43 @@ +/* + * 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.system.pdf.io + +import android.content.Context +import java.io.File + +private const val REPORTS_SUBDIR = "reports" + +class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { + + private val reportsDir + get() = File(context.cacheDir, REPORTS_SUBDIR) + + override fun newFilePath(name: String): String { + val outputDir = reportsDir.apply { mkdirs() } + return File(outputDir, "$name.pdf").absolutePath + } + + override fun exists(name: String): Boolean = File(reportsDir, "$name.pdf").exists() + + override fun listFiles(): List = + reportsDir + .listFiles { f -> f.isFile && f.extension == "pdf" } + ?.map { PdfOutputProvider.CachedPdf(it.absolutePath, it.lastModified()) } ?: emptyList() + + override fun deleteReport(path: String) { + File(path).delete() + } +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt new file mode 100644 index 0000000000..bdcb48f842 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.system.pdf.io + +import kotlin.time.Clock + +private const val MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1000 // 1 week + +/** Manages file paths and cached PDF reports. */ +interface PdfOutputProvider { + fun newFilePath(name: String): String + + fun exists(name: String): Boolean + + fun listFiles(): List + + fun deleteReport(path: String) + + /** Removes cached reports older than [MAX_AGE_MILLIS] (1 week). */ + fun pruneOldFiles() { + val now = Clock.System.now().toEpochMilliseconds() + listFiles() + .filter { now - it.lastModifiedMillis > MAX_AGE_MILLIS } + .forEach { deleteReport(it.path) } + } + + /** A cached PDF file entry with its path and last-modified timestamp. */ + data class CachedPdf(val path: String, val lastModifiedMillis: Long) +} From 3c1af259b2f34f27a7b9a91ab6aba6b01bdc162f Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:50:56 +0200 Subject: [PATCH 16/17] add PdfReportLauncher and implementation --- .../system/pdf/io/AndroidPdfReportLauncher.kt | 57 +++++++++++++++++++ .../ui/system/pdf/io/PdfReportLauncher.kt | 26 +++++++++ 2 files changed, 83 insertions(+) create mode 100644 core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..c97998fd17 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt @@ -0,0 +1,57 @@ +/* + * 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.system.pdf.io + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +private const val PDF_MIME_TYPE = "application/pdf" + +/** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +class AndroidPdfReportLauncher( + private val context: Context, + private val fileProviderAuthority: String, +) : PdfReportLauncher { + + override fun share(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val sendIntent = + Intent(Intent.ACTION_SEND).apply { + type = PDF_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(sendIntent) + } + + override fun open(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val viewIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, PDF_MIME_TYPE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(viewIntent) + } + + private fun launchChooser(target: Intent) { + val chooser = + Intent.createChooser(target, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(chooser) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt new file mode 100644 index 0000000000..ee25f0138e --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt @@ -0,0 +1,26 @@ +/* + * 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.system.pdf.io + +/** + * Presents the two terminal actions on a generated report: share via system share sheet, open in an + * external viewer. + */ +interface PdfReportLauncher { + fun share(path: String) + + fun open(path: String) +} From 3add8ed9f88db2712610fe4adb265daa246f06e1 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 25 May 2026 17:51:47 +0200 Subject: [PATCH 17/17] add PdfExportService and implement it in the fragments --- .../android/di/PdfReportModule.kt | 76 ++++++++++++++++++ .../datacollection/DataCollectionFragment.kt | 47 ++++++++--- .../HomeScreenMapContainerFragment.kt | 38 +++++++-- .../ui/system/pdf/PdfExportService.kt | 77 +++++++++++++++++++ 4 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt diff --git a/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt new file mode 100644 index 0000000000..9fffa8726a --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt @@ -0,0 +1,76 @@ +/* + * 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.IoDispatcher +import org.groundplatform.ui.system.pdf.AndroidPdfRenderer +import org.groundplatform.ui.system.pdf.PdfExportService +import org.groundplatform.ui.system.pdf.PdfRenderer +import org.groundplatform.ui.system.pdf.image.AndroidPdfImageProvider +import org.groundplatform.ui.system.pdf.image.PdfImageProvider +import org.groundplatform.ui.system.pdf.io.AndroidPdfOutputProvider +import org.groundplatform.ui.system.pdf.io.AndroidPdfReportLauncher +import org.groundplatform.ui.system.pdf.io.PdfOutputProvider +import org.groundplatform.ui.system.pdf.io.PdfReportLauncher + +@Module +@InstallIn(SingletonComponent::class) +object PdfReportModule { + + @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(): PdfRenderer = AndroidPdfRenderer() + + @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, + @IoDispatcher coroutineDispatcher: CoroutineDispatcher, + ): PdfExportService = + PdfExportService( + imageProvider = imageProvider, + renderer = renderer, + outputProvider = outputProvider, + launcher = launcher, + coroutineDispatcher = coroutineDispatcher, + ) +} 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 9c76cb9c98..96a666a2e4 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 @@ -20,9 +20,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.hilt.navigation.fragment.hiltNavGraphViewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener @@ -30,13 +32,15 @@ import org.groundplatform.android.ui.common.EphemeralPopups import org.groundplatform.android.ui.home.HomeScreenViewModel import org.groundplatform.android.util.createComposeView import org.groundplatform.android.util.openAppSettings -import org.groundplatform.android.util.shareLoiReportPdf import org.groundplatform.ui.components.loireport.LoiReportAction +import org.groundplatform.ui.mapper.LoiReportMapper +import org.groundplatform.ui.system.pdf.PdfExportService /** Fragment allowing the user to collect data to complete a task. */ @AndroidEntryPoint class DataCollectionFragment : AbstractFragment(), BackPressListener { @Inject lateinit var popups: EphemeralPopups + @Inject lateinit var pdfExportService: PdfExportService val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection) @@ -57,17 +61,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, - onLoiReportAction = { loiReportAction -> - when (loiReportAction) { - is LoiReportAction.OnShareClicked -> - { - /* TODO */ - } - is LoiReportAction.OnPdfItemClicked -> { - /* TODO */ - } - } - }, + onLoiReportAction = { handleLoiReportAction(it) }, ) } @@ -104,6 +98,35 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { findNavController().navigateUp() } + private fun handleLoiReportAction(action: LoiReportAction) { + val loiReport = + (viewModel.uiState.value as? DataCollectionUiState.TaskSubmitted)?.loiReport + ?: run { + popups.ErrorPopup().unknownError() + return + } + val submission = + loiReport.submissionDetails?.submissions?.firstOrNull() + ?: run { + popups.ErrorPopup().unknownError() + return + } + + lifecycleScope.launch { + val request = LoiReportMapper.map(loiReport, submission) + if (request == null) { + popups.ErrorPopup().unknownError() + return@launch + } + val pdfAction = + when (action) { + is LoiReportAction.OnShareClicked -> PdfExportService.Action.Share + is LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open + } + pdfExportService.export(request, pdfAction) + } + } + companion object { const val TASK_ID: String = "taskId" } 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 c9372746c4..04fa9ce9c0 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 @@ -22,9 +22,11 @@ import android.view.ViewGroup import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.databinding.BasemapLayoutBinding import org.groundplatform.android.ui.common.AbstractMapContainerFragment @@ -42,10 +44,11 @@ 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.android.util.shareLoiReportPdf import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.ui.components.loireport.LoiReportAction +import org.groundplatform.ui.mapper.LoiReportMapper +import org.groundplatform.ui.system.pdf.PdfExportService import timber.log.Timber /** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */ @@ -53,6 +56,7 @@ import timber.log.Timber class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { @Inject lateinit var ephemeralPopups: EphemeralPopups + @Inject lateinit var pdfExportService: PdfExportService private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel private lateinit var homeScreenViewModel: HomeScreenViewModel @@ -214,13 +218,33 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } private fun handleLoiReportAction(action: LoiReportAction) { - when (action) { - is LoiReportAction.OnShareClicked -> { - /* TODO */ - } - is LoiReportAction.OnPdfItemClicked -> { - /* TODO() */ + val loiReport = + (mapContainerViewModel.jobMapComponentState.value as? JobMapComponentState.LoiSelected) + ?.loi + ?.loiReport + ?: run { + ephemeralPopups.ErrorPopup().unknownError() + return + } + val submission = + loiReport.submissionDetails?.submissions?.firstOrNull() + ?: run { + ephemeralPopups.ErrorPopup().unknownError() + return + } + + lifecycleScope.launch { + val request = LoiReportMapper.map(loiReport, submission) + if (request == null) { + ephemeralPopups.ErrorPopup().unknownError() + return@launch } + val pdfAction = + when (action) { + is LoiReportAction.OnShareClicked -> PdfExportService.Action.Share + is LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open + } + pdfExportService.export(request, pdfAction) } } diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt new file mode 100644 index 0000000000..1983b96e19 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt @@ -0,0 +1,77 @@ +/* + * 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.system.pdf + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.model.SubmissionPdfDocument.Answer +import org.groundplatform.ui.system.pdf.image.PdfImageProvider +import org.groundplatform.ui.system.pdf.io.PdfOutputProvider +import org.groundplatform.ui.system.pdf.io.PdfReportLauncher + +/** + * Shared entry point for the PDF export flow. + * + * Loads images, renders the document to disk, then opens or shares the file using + * [PdfReportLauncher]. + */ +class PdfExportService( + private val imageProvider: PdfImageProvider, + private val renderer: PdfRenderer, + private val outputProvider: PdfOutputProvider, + private val launcher: PdfReportLauncher, + private val coroutineDispatcher: CoroutineDispatcher, +) { + suspend fun export(request: Request, action: Action) { + val outputPath = + withContext(coroutineDispatcher) { + outputProvider.pruneOldFiles() + val path = outputProvider.newFilePath(request.fileName) + if (!outputProvider.exists(request.fileName)) { + val images = imageProvider.load(request.qrContent, request.document.photoFilenames()) + try { + renderer.render(request.document, images, path) + } finally { + images.release() + } + } + path + } + when (action) { + Action.Open -> launcher.open(outputPath) + Action.Share -> launcher.share(outputPath) + } + } + + enum class Action { + Open, + Share, + } + + data class Request( + val document: SubmissionPdfDocument, + val qrContent: String?, + val fileName: String, + ) +} + +/** The distinct, non-empty photo filenames referenced by the document's table rows. */ +private fun SubmissionPdfDocument.photoFilenames(): Set = + table.rows + .mapNotNull { (it.answer as? Answer.Photo)?.remoteFilename } + .filter { it.isNotEmpty() } + .toSet()