From 660f653017ccad73b22cb34c4ceddc9ab0f09cd4 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:33:35 +0200 Subject: [PATCH 01/50] add new strings for pdf labels --- core/ui/src/commonMain/composeResources/values-es/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-fr/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-lo/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-pt/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-th/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-vi/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values/strings.xml | 4 ++++ 7 files changed, 28 insertions(+) diff --git a/core/ui/src/commonMain/composeResources/values-es/strings.xml b/core/ui/src/commonMain/composeResources/values-es/strings.xml index 1441bfe99a..20c08f2b4c 100644 --- a/core/ui/src/commonMain/composeResources/values-es/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml @@ -29,4 +29,8 @@ S E O + Presentación + Encuesta + Trabajo + Recolector de datos diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml index 04072375f2..faa708a94a 100644 --- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -28,4 +28,8 @@ S E O + Soumission + Enquête + Mission + Collecteur de données diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml index c95e27b274..8d231efd3a 100644 --- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml @@ -28,4 +28,8 @@ ຕ.ອ ຕ.ຕ + ການສົ່ງຂໍ້ມູນ + ແບບສຳຫຼວດ + ພາລະກິດ + ຜູ້ເກັບຂໍ້ມູນ diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml index bc6c073d85..fb03d932f4 100644 --- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml @@ -29,4 +29,8 @@ S E O + Submissão + Inquérito + Tarefa + Coletor de dados diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml index e9c343f710..64e8abec9a 100644 --- a/core/ui/src/commonMain/composeResources/values-th/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml @@ -28,4 +28,8 @@ ท.ใต้ ต.ออก ต.ตก + การส่งข้อมูล + แบบสำรวจ + งาน + ผู้เก็บข้อมูล diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml index 2a1ef5fe2f..a6e9eb9712 100644 --- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml @@ -28,4 +28,8 @@ Nam Đông Tây + bài gửi + Khảo sát + Công việc + Người thu thập dữ liệu diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index d7a73b1469..5b19452a85 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -29,4 +29,8 @@ S E W + Submission + Survey + Job + Data collector From 3b8cb9ebc0b232d484d91701939ebf76f7630572 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:38:45 +0200 Subject: [PATCH 02/50] add entry point for pdf export PdfExportService and the interfaces that will handle the different steps --- app/src/main/res/xml/file_paths.xml | 3 + .../ui/system/pdf/render/image/PdfImage.kt} | 19 +++-- .../ui/system/pdf/PdfExportService.kt | 71 +++++++++++++++++++ .../ui/system/pdf/PdfImageProvider.kt | 26 +++++++ .../ui/system/pdf/PdfOutputProvider.kt | 42 +++++++++++ .../ui/system/pdf/PdfRenderer.kt | 28 ++++++++ .../ui/system/pdf/PdfReportLauncher.kt | 26 +++++++ .../ui/system/pdf/render/image/PdfImage.kt | 25 +++++++ .../ui/system/pdf/render/image/PdfImageSet.kt | 37 ++++++++++ .../ui/system/pdf/render/image/PdfImage.kt | 30 ++++++++ 10 files changed, 301 insertions(+), 6 deletions(-) rename core/ui/src/{jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt => androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt} (59%) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt create mode 100644 core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.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/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt similarity index 59% rename from core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt rename to core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt index edc40ef030..df3fd43328 100644 --- a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt @@ -13,11 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.components.qrcode +package org.groundplatform.ui.system.pdf.render.image -import androidx.compose.ui.graphics.ImageBitmap +import android.graphics.Bitmap -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap = - // The JVM target exists only to unit-test platform-independent logic, so QR generation is - // intentionally unimplemented here. - throw UnsupportedOperationException("QR code generation is not supported on the JVM target") +/** + * Android wraps a [android.graphics.Bitmap]. The renderer reads [bitmap] directly to draw on a + * Canvas. + */ +actual data 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/system/pdf/PdfExportService.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt new file mode 100644 index 0000000000..e1ce864805 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt @@ -0,0 +1,71 @@ +/* + * 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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.groundplatform.ui.model.SubmissionPdfDocument + +/** + * 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, +) { + private val mutex = Mutex() + + suspend fun export(request: Request, action: Action) { + val outputPath = mutex.withLock { + 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, + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt new file mode 100644 index 0000000000..d8d0d7fde3 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.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 + +import org.groundplatform.ui.system.pdf.render.image.PdfImageSet + +/** + * 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/PdfOutputProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt new file mode 100644 index 0000000000..4f75a937fe --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/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 + +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) +} 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..6fe97b60ae --- /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.render.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) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt new file mode 100644 index 0000000000..c6d764e288 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/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 + +/** + * 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) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt new file mode 100644 index 0000000000..c4d3cceaa1 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/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.render.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/render/image/PdfImageSet.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt new file mode 100644 index 0000000000..ee5d874606 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/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.render.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/system/pdf/render/image/PdfImage.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt new file mode 100644 index 0000000000..aa620bbec6 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/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.render.image + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth +import platform.CoreGraphics.CGImageRef + +@OptIn(ExperimentalForeignApi::class) +actual data class PdfImage(val cgImage: CGImageRef) { + actual val width: Int + get() = CGImageGetWidth(cgImage).toInt() + + actual val height: Int + get() = CGImageGetHeight(cgImage).toInt() +} From b061d06b43134d5a9d2e97bba48cd2cede5de4f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:46:17 +0200 Subject: [PATCH 03/50] update LoiReport and add mapper to map the data to the PdfExportService.Request --- .../DataCollectionScreenPreviews.kt | 5 +- .../DataSubmissionConfirmationScreen.kt | 20 ++- .../mapcontainer/jobs/ShareLocationModal.kt | 20 ++- .../org/groundplatform/android/FakeData.kt | 5 +- .../DataSubmissionConfirmationScreenTest.kt | 10 +- .../home/mapcontainer/jobs/LoiJobSheetTest.kt | 5 +- .../jobs/ShareLocationModalTest.kt | 13 +- .../model/locationofinterest/LoiReport.kt | 15 +- .../domain/usecases/GetLoiReportUseCase.kt | 8 +- core/testing/build.gradle.kts | 1 + .../testing/FakeDataGenerator.kt | 49 +++++++ .../ui/mapper/LoiReportMapper.kt | 107 +++++++++++++++ .../ui/mapper/LoiReportMapperTest.kt | 128 ++++++++++++++++++ 13 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt create mode 100644 core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt 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..d3777b4adc 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 @@ -101,12 +101,9 @@ private fun DataCollectionContentCompletePreview() { DataCollectionUiState.TaskSubmitted( loiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - 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 6b1b24b742..4ae3e98a9e 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,15 +45,13 @@ 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 import androidx.compose.ui.unit.dp +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date -import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -63,6 +61,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @Composable fun DataSubmissionConfirmationScreen( @@ -164,13 +163,13 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport ) } - 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 = { /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ }, @@ -185,9 +184,6 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport private val testLoiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Test LOI", geoJson = JsonObject( @@ -203,7 +199,7 @@ private val testLoiReport = ), ) ), - submissions = emptyList(), + submissionDetails = null, ) @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 54e9c1b782..80f5a3f12f 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,16 +38,14 @@ 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 import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date -import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -56,6 +54,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -95,13 +94,13 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { ) } - 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 = { /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ }, @@ -128,9 +127,6 @@ private fun ShareLocationModalPreview() { val testLoiReport = LoiReport( loiName = "Test LOI", - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), geoJson = JsonObject( mapOf( @@ -145,7 +141,7 @@ private fun ShareLocationModalPreview() { ), ) ), - submissions = emptyList(), + submissionDetails = null, ) AppTheme { diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 5e7892b7c8..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -111,10 +111,7 @@ object FakeData { val LOCATION_OF_INTEREST_LOI_REPORT = LoiReport( - surveyName = SURVEY.title, loiName = "Unnamed point", - userName = USER.displayName, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, geoJson = JsonObject( mapOf( @@ -132,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 d5c0226660..3b558eea13 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 @@ -32,6 +32,7 @@ import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.junit.Assert.assertTrue @@ -104,7 +105,7 @@ class DataSubmissionConfirmationScreenTest { fun `Shows the PDF item when submissions is not null`() { composeTestRule.setContent { DataSubmissionConfirmationScreen( - loiReport = LOI_REPORT.copy(submissions = emptyList()), + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismissed = {}, ) } @@ -116,7 +117,7 @@ class DataSubmissionConfirmationScreenTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { DataSubmissionConfirmationScreen( - loiReport = LOI_REPORT.copy(submissions = null), + loiReport = LOI_REPORT.copy(submissionDetails = null), onDismissed = {}, ) } @@ -140,10 +141,7 @@ class DataSubmissionConfirmationScreenTest { private companion object { private val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = "Test LOI", - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -158,7 +156,7 @@ class DataSubmissionConfirmationScreenTest { ), ) ), - submissions = emptyList(), + submissionDetails = null, ) } } 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..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,10 +125,7 @@ class LoiJobSheetTest { private fun getLoiReport(name: String): LoiReport = LoiReport( - surveyName = "Test Survey", loiName = name, - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -143,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 eee9bda893..8ffad04946 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 @@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.groundplatform.ui.theme.AppTheme @@ -61,7 +62,10 @@ class ShareLocationModalTest { fun `Shows the PDF item when submissions is not null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissions = emptyList()), onDismiss = {}) + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismiss = {}, + ) } } @@ -72,7 +76,7 @@ class ShareLocationModalTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissions = null), onDismiss = {}) + ShareLocationModal(loiReport = LOI_REPORT.copy(submissionDetails = null), onDismiss = {}) } } @@ -96,10 +100,7 @@ class ShareLocationModalTest { const val LOI_NAME = "Test Loi" val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = LOI_NAME, - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -114,7 +115,7 @@ class ShareLocationModalTest { ), ) ), - submissions = null, + submissionDetails = null, ) } } 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..f558df72f5 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,10 +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 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 1078b0d2f1..e6ef3e833e 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 @@ -56,19 +56,15 @@ class GetLoiReportUseCase( val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() - val submissions = null // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 return loi?.let { LoiReport( - 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, + submissionDetails = null, // To be implemented in a follow-up on + // https://github.com/google/ground-android/issues/3715 ) } } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index fa9c565cbd..89b455a0ad 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) } } } 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..a4ad9d8f99 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt @@ -16,6 +16,7 @@ package org.groundplatform.testing import kotlin.time.Clock +import kotlinx.serialization.json.JsonObject import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem import org.groundplatform.domain.model.User @@ -27,6 +28,7 @@ import org.groundplatform.domain.model.job.Style import org.groundplatform.domain.model.locationofinterest.AuditInfo import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.mutation.LocationOfInterestMutation import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.Mutation.SyncStatus.PENDING @@ -34,6 +36,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 +136,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, @@ -220,4 +243,30 @@ object FakeDataGenerator { availableOffline: Boolean = false, generalAccess: Survey.GeneralAccess = Survey.GeneralAccess.PUBLIC, ) = SurveyListItem(id, title, description, availableOffline, generalAccess) + + fun newSubmissionDetails( + surveyName: String = "survey", + userName: String = "user", + userEmail: String = "user@email.com", + dateMillis: Long = 0L, + submissions: List = listOf(newSubmission()), + ): LoiReport.SubmissionDetails = + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = userName, + userEmail = userEmail, + dateMillis = dateMillis, + submissions = submissions, + ) + + fun newLoiReport( + loiName: String = "loi", + geoJson: JsonObject = JsonObject(emptyMap()), + submissionDetails: LoiReport.SubmissionDetails? = newSubmissionDetails(), + ): LoiReport = + LoiReport( + loiName = loiName, + geoJson = geoJson, + submissionDetails = submissionDetails, + ) } 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..f706fc5011 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt @@ -0,0 +1,107 @@ +/* + * 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_data_collector +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.ui.model.SubmissionPdfDocument +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.DateFormatter +import org.groundplatform.ui.util.StringResolver + +/** Maps an [LoiReport] and its [Submission] to a [PdfExportService.Request]. */ +class LoiReportMapper( + private val taskValueMapper: TaskValueMapper, + private val strings: StringResolver, + private val dateFormatter: DateFormatter, +) { + + 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 = strings.resolve(Res.string.submission), + loiName = loiReport.loiName, + rows = rows, + ), + ) + val timestamp = + "${dateFormatter.formatDate(details.dateMillis)}_${dateFormatter.formatTime(details.dateMillis)}" + val fileName = + listOf(details.surveyName, loiReport.loiName, details.userName, timestamp) + .map { it.filter(::isSafeFileChar) } + .filter { it.isNotBlank() } + .joinToString("_") + .take(200) + + return PdfExportService.Request( + document = document, + qrContent = loiReport.geoJson.toString(), + fileName = fileName, + ) + } + + private suspend fun buildHeader( + details: LoiReport.SubmissionDetails, + submission: Submission, + ): Header = + Header( + surveyLabel = strings.resolve(Res.string.survey), + surveyName = details.surveyName, + jobLabel = strings.resolve(Res.string.job), + jobName = submission.job.name ?: submission.job.id, + timestamp = + "${dateFormatter.formatDate(details.dateMillis)} ${dateFormatter.formatTime(details.dateMillis)}", + ) + + private suspend fun buildQrBlock(): QrBlock = + QrBlock(scanCaption = strings.resolve(Res.string.scan_this_qr_to_download_geojson)) + + private suspend fun buildFooter(details: LoiReport.SubmissionDetails): Footer = + Footer( + dataCollectorLabel = strings.resolve(Res.string.pdf_data_collector), + dataCollectorName = details.userName, + userEmail = details.userEmail, + ) + + private suspend fun buildRows(submission: Submission): List = + submission.job.tasksSorted + .filter { !it.isOmittedFromDocExport() } + .mapNotNull { task -> + submission.data.getValue(task.id)?.let { value -> + Row(question = task.label, answer = taskValueMapper.map(task, value)) + } + } + + private fun isSafeFileChar(c: Char): Boolean = + c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-" +} diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt new file mode 100644 index 0000000000..a8921a0e7f --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt @@ -0,0 +1,128 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.util.FakeDateFormatter +import org.groundplatform.ui.util.FakeStringResolver + +class LoiReportMapperTest { + + private val mapper = + LoiReportMapper( + taskValueMapper = + TaskValueMapper(strings = FakeStringResolver, dateFormatter = FakeDateFormatter), + strings = FakeStringResolver, + dateFormatter = FakeDateFormatter, + ) + + private val timestampSegment = "DATE0_TIME0" + + @Test + fun `file name joins survey, loi, user, and timestamp with underscores`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "Loi", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "Survey", userName = "User"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("Survey_Loi_User_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name strips characters outside the ASCII safe set`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "loi/name?", + submissionDetails = + FakeDataGenerator.newSubmissionDetails( + surveyName = "My Survey!", + userName = "user@email.com", + ), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("MySurvey_loiname_useremailcom_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name drops blank segments without leaving double underscores`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "loi", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "", userName = "@@@"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("loi_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name strips non-ASCII characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "ß", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "café", userName = "José"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + // "café" -> "caf", "ß" -> "", "José" -> "Jos". + assertEquals("caf_Jos_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name is capped at 200 characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "x", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "a".repeat(300), userName = "y"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals(200, request!!.fileName.length) + } + + @Test + fun `map returns null when submissionDetails are missing`() = runTest { + val report = FakeDataGenerator.newLoiReport(submissionDetails = null) + + assertNull(mapper.map(report, FakeDataGenerator.newSubmission())) + } +} From 0aa877edcc6040a0df57c19847261d80ebbdf8a5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:48:31 +0200 Subject: [PATCH 04/50] remove unneeded code from gradle and move ComposeStringResourcesTest to androidHostTest --- core/ui/build.gradle.kts | 4 ++-- .../ui/resources/ComposeStringResourcesTest.kt | 0 .../org/groundplatform/ui/model/SubmissionPdfDocument.kt | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) rename core/ui/src/{jvmTest => androidHostTest}/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt (100%) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5d71bdb988..c6c8bb7246 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -34,8 +34,6 @@ kotlin { withHostTest { isIncludeAndroidResources = true } } - jvm() - val xcfName = "GroundUiKit" listOf(iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { @@ -56,6 +54,8 @@ kotlin { 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) } } diff --git a/core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt similarity index 100% rename from core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt rename to core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt 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 index 2bc0b96325..80eb1d4e76 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt @@ -51,4 +51,11 @@ data class SubmissionPdfDocument( val dataCollectorName: String, val userEmail: String, ) + + /** The distinct, non-empty photo filenames referenced by the document's table rows. */ + fun photoFilenames(): Set = + table.rows + .mapNotNull { (it.answer as? Answer.Photo)?.remoteFilename } + .filter { it.isNotEmpty() } + .toSet() } From 414a2d3d6ecb92805b12f1006d156cefa02a16b5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:50:35 +0200 Subject: [PATCH 05/50] fix codecheck issues --- .../ui/datacollection/DataCollectionScreenPreviews.kt | 7 +------ .../groundplatform/domain/usecases/GetLoiReportUseCase.kt | 1 + .../kotlin/org/groundplatform/testing/FakeDataGenerator.kt | 6 +----- 3 files changed, 3 insertions(+), 11 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 d3777b4adc..bd1c6946a9 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 @@ -100,11 +99,7 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport( - loiName = "Point A", - geoJson = JsonObject(mapOf()), - submissionDetails = null - ) + LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), submissionDetails = null) ), onCloseClicked = {}, ) { 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 e6ef3e833e..e4a3985363 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 @@ -52,6 +52,7 @@ class GetLoiReportUseCase( * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ + @Suppress("UnusedPrivateProperty") suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() 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 a4ad9d8f99..5d82702f54 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt @@ -264,9 +264,5 @@ object FakeDataGenerator { geoJson: JsonObject = JsonObject(emptyMap()), submissionDetails: LoiReport.SubmissionDetails? = newSubmissionDetails(), ): LoiReport = - LoiReport( - loiName = loiName, - geoJson = geoJson, - submissionDetails = submissionDetails, - ) + LoiReport(loiName = loiName, geoJson = geoJson, submissionDetails = submissionDetails) } From 858a7c3e83f9a038e86a18e4771aaedd0f17b535 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 12:36:07 +0200 Subject: [PATCH 06/50] extract pdf code to sepparate feature module --- core/testing/build.gradle.kts | 16 +++++- .../testing}/FakeDateFormatter.kt | 4 +- .../testing}/FakeStringResolver.kt | 3 +- core/ui/build.gradle.kts | 5 +- feature/pdf/.gitignore | 1 + feature/pdf/build.gradle.kts | 57 +++++++++++++++++++ .../pdf/render/image/PdfImage.android.kt | 4 +- .../feature}/pdf/PdfExportService.kt | 4 +- .../feature}/pdf/PdfImageProvider.kt | 4 +- .../feature}/pdf/PdfOutputProvider.kt | 2 +- .../feature}/pdf/PdfRenderer.kt | 6 +- .../feature}/pdf/PdfReportLauncher.kt | 2 +- .../feature/pdf}/mapper/LoiReportMapper.kt | 17 +++--- .../feature/pdf}/mapper/TaskValueMapper.kt | 11 ++-- .../pdf}/model/SubmissionPdfDocument.kt | 2 +- .../feature}/pdf/render/image/PdfImage.kt | 2 +- .../feature}/pdf/render/image/PdfImageSet.kt | 2 +- .../pdf}/mapper/LoiReportMapperTest.kt | 8 +-- .../pdf}/mapper/TaskValueMapperTest.kt | 37 ++++++------ .../feature/pdf/render/image/PdfImage.ios.kt | 2 +- settings.gradle | 1 + 21 files changed, 133 insertions(+), 57 deletions(-) rename core/{ui/src/commonTest/kotlin/org/groundplatform/ui/util => testing/src/commonMain/kotlin/org/groundplatform/testing}/FakeDateFormatter.kt (91%) rename core/{ui/src/commonTest/kotlin/org/groundplatform/ui/util => testing/src/commonMain/kotlin/org/groundplatform/testing}/FakeStringResolver.kt (92%) create mode 100644 feature/pdf/.gitignore create mode 100644 feature/pdf/build.gradle.kts rename core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt => feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt (84%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfExportService.kt (95%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfImageProvider.kt (89%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfOutputProvider.kt (97%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfRenderer.kt (84%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfReportLauncher.kt (95%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/mapper/LoiReportMapper.kt (87%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/mapper/TaskValueMapper.kt (93%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/model/SubmissionPdfDocument.kt (97%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/render/image/PdfImage.kt (93%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/render/image/PdfImageSet.kt (95%) rename {core/ui/src/commonTest/kotlin/org/groundplatform/ui => feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf}/mapper/LoiReportMapperTest.kt (94%) rename {core/ui/src/commonTest/kotlin/org/groundplatform/ui => feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf}/mapper/TaskValueMapperTest.kt (82%) rename core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt => feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt (94%) diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 89b455a0ad..ee1a78ca3b 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -13,11 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -plugins { alias(libs.plugins.kotlin.multiplatform) } +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} kotlin { - jvm() jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) + androidLibrary { + namespace = "org.groundplatform.core.testing" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + minSdk = libs.versions.androidMinSdk.get().toInt() + } + + jvm() iosArm64() iosSimulatorArm64() @@ -26,10 +36,12 @@ kotlin { commonMain { dependencies { implementation(project(":core:domain")) + implementation(project(":core:ui")) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.components.resources) } } } diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt similarity index 91% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt rename to core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt index a22a47e26d..7cb4172eb8 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.util +package org.groundplatform.testing + +import org.groundplatform.ui.util.DateFormatter /** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ object FakeDateFormatter : DateFormatter { diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt similarity index 92% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt rename to core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt index 251041f9db..dab6891631 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.util +package org.groundplatform.testing +import org.groundplatform.ui.util.StringResolver import org.jetbrains.compose.resources.StringResource /** diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c6c8bb7246..5a997471c8 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -51,11 +51,8 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) - implementation(libs.compose.components.resources) - implementation(libs.androidx.lifecycle.runtime.compose) + api(libs.compose.components.resources) implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) } } diff --git a/feature/pdf/.gitignore b/feature/pdf/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/pdf/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts new file mode 100644 index 0000000000..953e7adfce --- /dev/null +++ b/feature/pdf/build.gradle.kts @@ -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. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} + +kotlin { + androidLibrary { + namespace = "org.groundplatform.feature.pdf" + compileSdk { version = release(libs.versions.androidCompileSdk.get().toInt()) } + minSdk = libs.versions.androidMinSdk.get().toInt() + withHostTest {} + } + + val xcfName = "GroundFeaturePdfKit" + listOf(iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = xcfName + isStatic = true + } + } + sourceSets { + commonMain { + dependencies { + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) + } + } + + commonTest { + dependencies { + implementation(project(":core:testing")) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt similarity index 84% rename from core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt index df3fd43328..03b460c2fe 100644 --- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image import android.graphics.Bitmap /** - * Android wraps a [android.graphics.Bitmap]. The renderer reads [bitmap] directly to draw on a + * Android wraps a [Bitmap]. The renderer reads [bitmap] directly to draw on a * Canvas. */ actual data class PdfImage(val bitmap: Bitmap) { diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt index e1ce864805..ef3068630d 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument /** * Shared entry point for the PDF export flow. diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt similarity index 89% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt index d8d0d7fde3..49ef9047ab 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf -import org.groundplatform.ui.system.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet /** * Platform abstraction for images (QR code and photos) needed for PDF rendering. Implementations diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt similarity index 97% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt index 4f75a937fe..f5b1717394 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf import kotlin.time.Clock diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt similarity index 84% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt index 6fe97b60ae..7f04629636 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf -import org.groundplatform.ui.model.SubmissionPdfDocument -import org.groundplatform.ui.system.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet /** * Rasterises a [SubmissionPdfDocument] to a PDF file. Each platform should use its native text diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt index c6d764e288..79cad8b79e 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf /** * Presents the two terminal actions on a generated report: share via system share sheet, open in an diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt similarity index 87% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt index f706fc5011..c904d739af 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.job @@ -23,12 +23,13 @@ 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.ui.model.SubmissionPdfDocument -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.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Row +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Table import org.groundplatform.ui.util.DateFormatter import org.groundplatform.ui.util.StringResolver @@ -48,7 +49,7 @@ class LoiReportMapper( qrBlock = buildQrBlock(), footer = buildFooter(details), table = - SubmissionPdfDocument.Table( + Table( submissionLabel = strings.resolve(Res.string.submission), loiName = loiReport.loiName, rows = rows, diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt similarity index 93% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt index 121886bb8f..2a5b6d5ea7 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper -import androidx.annotation.VisibleForTesting import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.east import ground_android.core.ui.generated.resources.north @@ -25,6 +24,7 @@ import ground_android.core.ui.generated.resources.pdf_altitude 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.collections.orEmpty import kotlin.math.absoluteValue import kotlin.math.round import org.groundplatform.domain.model.geometry.Point @@ -38,7 +38,7 @@ import org.groundplatform.domain.model.submission.TextTaskData import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.util.toFixedDecimals -import org.groundplatform.ui.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer import org.groundplatform.ui.util.DateFormatter import org.groundplatform.ui.util.StringResolver @@ -102,11 +102,10 @@ class TaskValueMapper( return "${formatDegrees(lat)} $latDir, ${formatDegrees(lng)} $lngDir" } - @VisibleForTesting - fun formatDegrees(value: Double): String = + internal fun formatDegrees(value: Double): String = "${value.absoluteValue.toFixedDecimals(DEGREES_DECIMALS)}°" - @VisibleForTesting fun formatMeters(value: Double): String = round(value).toLong().toString() + internal fun formatMeters(value: Double): String = round(value).toLong().toString() companion object { private const val DEGREES_DECIMALS = 6 diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt similarity index 97% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt index 80eb1d4e76..8aa8bc1bbb 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.model +package org.groundplatform.feature.pdf.model /** * UI model for a submission PDF. Each property corresponds to a distinct visual section so that diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt similarity index 93% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt index c4d3cceaa1..b8e1015bd9 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image /** * Opaque platform image handle (Android `Bitmap`, iOS `CGImageRef`). The renderer reads the diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt index ee5d874606..74e001cd68 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image /** * The set of images needed to render a single report. Owns the lifecycle of the platform handles, diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt similarity index 94% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index a8921a0e7f..01ca24524a 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.ui.util.FakeDateFormatter -import org.groundplatform.ui.util.FakeStringResolver +import org.groundplatform.testing.FakeDateFormatter +import org.groundplatform.testing.FakeStringResolver class LoiReportMapperTest { @@ -36,7 +36,7 @@ class LoiReportMapperTest { private val timestampSegment = "DATE0_TIME0" @Test - fun `file name joins survey, loi, user, and timestamp with underscores`() = runTest { + fun `file name joins survey loi user and timestamp with underscores`() = runTest { val request = mapper.map( loiReport = diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt similarity index 82% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt index 04b24f772d..bfbddfd976 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import kotlin.test.Test import kotlin.test.assertEquals @@ -32,10 +32,10 @@ import org.groundplatform.domain.model.task.MultipleChoice import org.groundplatform.domain.model.task.Option import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.ui.model.SubmissionPdfDocument.Answer -import org.groundplatform.ui.util.FakeDateFormatter -import org.groundplatform.ui.util.FakeStringResolver +import org.groundplatform.testing.FakeDateFormatter +import org.groundplatform.testing.FakeStringResolver class TaskValueMapperTest { @@ -46,7 +46,7 @@ class TaskValueMapperTest { @Test fun `TEXT task maps to the same value`() = runTest { assertEquals( - Answer.Text(listOf("free text")), + SubmissionPdfDocument.Answer.Text(listOf("free text")), mapper.map(task(Task.Type.TEXT), TextTaskData("free text")), ) } @@ -54,7 +54,7 @@ class TaskValueMapperTest { @Test fun `NUMBER task maps to the same value`() = runTest { assertEquals( - Answer.Text(listOf("42")), + SubmissionPdfDocument.Answer.Text(listOf("42")), mapper.map(task(Task.Type.NUMBER), NumberTaskData("42")), ) } @@ -62,7 +62,7 @@ class TaskValueMapperTest { @Test fun `Skipped value renders the skipped label`() = runTest { assertEquals( - Answer.Text(listOf("skipped")), + SubmissionPdfDocument.Answer.Text(listOf("skipped")), mapper.map(task(Task.Type.TEXT), SkippedTaskData()), ) } @@ -70,7 +70,7 @@ class TaskValueMapperTest { @Test fun `PHOTO task maps to a photo answer`() = runTest { assertEquals( - Answer.Photo("path/to/photo.jpg"), + SubmissionPdfDocument.Answer.Photo("path/to/photo.jpg"), mapper.map(task(Task.Type.PHOTO), PhotoTaskData("path/to/photo.jpg")), ) } @@ -78,14 +78,17 @@ class TaskValueMapperTest { @Test fun `unsupported value maps to an empty answer`() = runTest { val value = DropPinTaskData(Point(Coordinates(1.0, 2.0))) - assertEquals(Answer.Text(emptyList()), mapper.map(task(Task.Type.DROP_PIN), value)) + assertEquals( + SubmissionPdfDocument.Answer.Text(emptyList()), + mapper.map(task(Task.Type.DROP_PIN), value), + ) } @Test fun `DATE task renders date only`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(listOf(dateFormatter.formatDate(millis))), + SubmissionPdfDocument.Answer.Text(listOf(dateFormatter.formatDate(millis))), mapper.map(task(Task.Type.DATE), DateTimeTaskData(millis)), ) } @@ -94,7 +97,7 @@ class TaskValueMapperTest { fun `TIME task renders time only`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(listOf(dateFormatter.formatTime(millis))), + SubmissionPdfDocument.Answer.Text(listOf(dateFormatter.formatTime(millis))), mapper.map(task(Task.Type.TIME), DateTimeTaskData(millis)), ) } @@ -103,7 +106,7 @@ class TaskValueMapperTest { fun `non date or time task renders empty answer`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(emptyList()), + SubmissionPdfDocument.Answer.Text(emptyList()), mapper.map(task(Task.Type.NUMBER), DateTimeTaskData(millis)), ) } @@ -112,7 +115,7 @@ class TaskValueMapperTest { fun `MULTIPLE_CHOICE renders each selected option label on its own line`() = runTest { val value = MultipleChoiceTaskData(multipleChoice(), selectedOptionIds = listOf("a", "b")) assertEquals( - Answer.Text(listOf("Apple", "Banana")), + SubmissionPdfDocument.Answer.Text(listOf("Apple", "Banana")), mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value), ) } @@ -122,7 +125,7 @@ class TaskValueMapperTest { val other = "${MultipleChoiceTaskData.OTHER_PREFIX}custom${MultipleChoiceTaskData.OTHER_SUFFIX}" val value = MultipleChoiceTaskData(multipleChoice(), selectedOptionIds = listOf("a", other)) assertEquals( - Answer.Text(listOf("Apple", "other: custom")), + SubmissionPdfDocument.Answer.Text(listOf("Apple", "other: custom")), mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value), ) } @@ -135,7 +138,7 @@ class TaskValueMapperTest { val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value) val expected = - Answer.Text( + SubmissionPdfDocument.Answer.Text( listOf( "${mapper.formatDegrees(1.5)} north, ${mapper.formatDegrees(2.25)} west", "pdf_altitude(${mapper.formatMeters(10.0)})", @@ -153,7 +156,9 @@ class TaskValueMapperTest { val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value) assertEquals( - Answer.Text(listOf("${mapper.formatDegrees(1.0)} south, ${mapper.formatDegrees(2.0)} east")), + SubmissionPdfDocument.Answer.Text( + listOf("${mapper.formatDegrees(1.0)} south, ${mapper.formatDegrees(2.0)} east") + ), result, ) } diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt similarity index 94% rename from core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt index aa620bbec6..adadacec43 100644 --- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreGraphics.CGImageGetHeight diff --git a/settings.gradle b/settings.gradle index 86bf7ba7ee..8b0871680a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -30,3 +30,4 @@ include ':app', ':e2eTest' include ':core:ui' include ':core:domain' include ':core:testing' +include ':feature:pdf' From 30ccb6457308a5434ae055621e74af12b94fa482 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 15:19:07 +0200 Subject: [PATCH 07/50] move testing helpers to feature:pdf --- core/testing/build.gradle.kts | 2 -- .../testing/FakeDateFormatter.kt | 26 ---------------- .../testing/FakeStringResolver.kt | 30 ------------------- .../feature/pdf/helpers/FakeDateFormatter.kt | 11 +++++++ .../feature/pdf/helpers/FakeStringResolver.kt | 16 ++++++++++ .../feature/pdf/mapper/LoiReportMapperTest.kt | 4 +-- .../feature/pdf/mapper/TaskValueMapperTest.kt | 4 +-- 7 files changed, 31 insertions(+), 62 deletions(-) delete mode 100644 core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index ee1a78ca3b..8d9a120036 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -36,12 +36,10 @@ kotlin { commonMain { dependencies { implementation(project(":core:domain")) - implementation(project(":core:ui")) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.compose.components.resources) } } } diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt deleted file mode 100644 index 7cb4172eb8..0000000000 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.testing - -import org.groundplatform.ui.util.DateFormatter - -/** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ -object FakeDateFormatter : DateFormatter { - - override fun formatDate(millis: Long): String = "DATE($millis)" - - override fun formatTime(millis: Long): String = "TIME($millis)" -} diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt deleted file mode 100644 index dab6891631..0000000000 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.testing - -import org.groundplatform.ui.util.StringResolver -import org.jetbrains.compose.resources.StringResource - -/** - * [StringResolver] for tests so display logic can be asserted without a Compose resource runtime. - */ -object FakeStringResolver : StringResolver { - - override suspend fun resolve(resource: StringResource): String = resource.key - - override suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String = - "${resource.key}(${formatArgs.joinToString()})" -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt new file mode 100644 index 0000000000..8d24c51e1b --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt @@ -0,0 +1,11 @@ +package org.groundplatform.feature.pdf.helpers + +import org.groundplatform.ui.util.DateFormatter + +/** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ +object FakeDateFormatter : DateFormatter { + + override fun formatDate(millis: Long): String = "DATE($millis)" + + override fun formatTime(millis: Long): String = "TIME($millis)" +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt new file mode 100644 index 0000000000..b68e91541b --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt @@ -0,0 +1,16 @@ +package org.groundplatform.feature.pdf.helpers + +import org.groundplatform.ui.util.StringResolver +import org.jetbrains.compose.resources.StringResource + +/** + * [org.groundplatform.ui.util.StringResolver] for tests so display logic can be asserted without a + * Compose resource runtime. + */ +object FakeStringResolver : StringResolver { + + override suspend fun resolve(resource: StringResource): String = resource.key + + override suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String = + "${resource.key}(${formatArgs.joinToString()})" +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index 01ca24524a..379d2068d6 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -19,9 +19,9 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakeStringResolver import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.testing.FakeDateFormatter -import org.groundplatform.testing.FakeStringResolver class LoiReportMapperTest { diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt index bfbddfd976..e146ad0f4f 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt @@ -32,10 +32,10 @@ import org.groundplatform.domain.model.task.MultipleChoice import org.groundplatform.domain.model.task.Option import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakeStringResolver import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.testing.FakeDateFormatter -import org.groundplatform.testing.FakeStringResolver class TaskValueMapperTest { From f890701ff1e0b9045a7cb3cb5ee3159a7be4068a Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 15:31:38 +0200 Subject: [PATCH 08/50] fix failing tests --- .../DataSubmissionConfirmationScreen.kt | 28 ++++++++++--------- .../mapcontainer/jobs/ShareLocationModal.kt | 28 ++++++++++--------- .../domain/usecases/GetLoiReportUseCase.kt | 13 +++++++-- .../usecases/GetLoiReportUseCaseTest.kt | 6 ++-- 4 files changed, 43 insertions(+), 32 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 4ae3e98a9e..0b18ea8b3a 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 @@ -164,19 +164,21 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport } loiReport.submissionDetails?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) + if (!it.submissions.isNullOrEmpty()) { + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + onShareClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + ) + } } } } 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 80f5a3f12f..346a83f0ef 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 @@ -95,19 +95,21 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { } loiReport.submissionDetails?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) + if (!it.submissions.isNullOrEmpty()) { + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + onShareClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + ) + } } TextButton( 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 e4a3985363..0568c02072 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 @@ -52,11 +52,12 @@ class GetLoiReportUseCase( * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ - @Suppress("UnusedPrivateProperty") suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() + val submissions = null // To be implemented in a follow-up on + // https://github.com/google/ground-android/issues/3715 return loi?.let { LoiReport( loiName = loiName, @@ -64,8 +65,14 @@ class GetLoiReportUseCase( it.geometry.toGeoJson( it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } ), - submissionDetails = null, // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = user.displayName, + userEmail = user.email, + dateMillis = loi.lastModified.clientTimestamp, + 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..95a316eed7 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 @@ -319,8 +319,8 @@ class GetLoiReportUseCaseTest { getLoiReportUseCase.invoke(loiName = "Test LOI", loiId = "loiId", surveyId = "surveyId")!! assertEquals("Test LOI", loiReport.loiName) - assertEquals("John Doe", loiReport.userName) - assertEquals(987654321L, loiReport.dateMillis) + assertEquals("John Doe", loiReport.submissionDetails!!.userName) + assertEquals(987654321L, loiReport.submissionDetails.dateMillis) } @Test @@ -331,7 +331,7 @@ class GetLoiReportUseCaseTest { val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! - assertEquals("Restoration areas", loiReport.surveyName) + assertEquals("Restoration areas", loiReport.submissionDetails!!.surveyName) } private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { From 36a8524fe62dbb9cc3ceb40ada14599287675948 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 16:29:38 +0200 Subject: [PATCH 09/50] add missing file license --- .../feature/pdf/helpers/FakeDateFormatter.kt | 15 +++++++++++++++ .../feature/pdf/helpers/FakeStringResolver.kt | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt index 8d24c51e1b..638e21aaae 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.helpers import org.groundplatform.ui.util.DateFormatter diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt index b68e91541b..429efe244a 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.helpers import org.groundplatform.ui.util.StringResolver From 84c60de47cc79548489ca9ec7a24ad694ea20a83 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 17:01:11 +0200 Subject: [PATCH 10/50] fix test --- app/src/test/java/org/groundplatform/android/FakeData.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index e858c36b16..986cdd2be7 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -129,7 +129,14 @@ object FakeData { ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = SURVEY.title, + userName = USER.displayName, + userEmail = USER.email, + dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, + submissions = null, + ), ) val LOCATION_OF_INTEREST_FEATURE = Feature( From 4fac1a0f495d00457aa6760b9ae2008f64232670 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 1 Jun 2026 10:38:32 +0200 Subject: [PATCH 11/50] handle cases where rendering might fail and create a corrupt file --- .../feature/pdf/PdfExportService.kt | 5 + .../feature/pdf/PdfExportServiceTest.kt | 129 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt index ef3068630d..c5ad3215a8 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt @@ -45,6 +45,11 @@ class PdfExportService( val images = imageProvider.load(request.qrContent, request.document.photoFilenames()) try { renderer.render(request.document, images, path) + } catch (e: Throwable) { + // Delete the partially written or corrupted file so we never open/share a broken PDF, + // then re-throw so the failure can be handled upstream. + outputProvider.deleteReport(path) + throw e } finally { images.release() } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt new file mode 100644 index 0000000000..a2334dbb76 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +class PdfExportServiceTest { + + private val path = "/tmp/report.pdf" + private val deletedPaths = mutableListOf() + private var imagesReleased = false + private var sharedPath: String? = null + private var openedPath: String? = null + + @Test + fun `deletes corrupted file and rethrows when rendering fails`() = runTest { + val failure = RuntimeException("out of memory") + + val thrown = + assertFailsWith { + service(renderError = failure).export(request, PdfExportService.Action.Open) + } + + assertEquals(failure, thrown) + assertEquals(listOf(path), deletedPaths) + assertTrue(imagesReleased) + } + + @Test + fun `does not delete file and launches when rendering succeeds`() = runTest { + service().export(request, PdfExportService.Action.Share) + + assertTrue(deletedPaths.isEmpty()) + assertEquals(path, sharedPath) + assertTrue(imagesReleased) + } + + private fun service(renderError: Throwable? = null) = + PdfExportService( + imageProvider = + object : PdfImageProvider { + override suspend fun load(qrContent: String?, photoFilenames: Set) = + PdfImageSet(images = emptyMap(), onRelease = { imagesReleased = true }) + }, + renderer = + object : PdfRenderer { + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + renderError?.let { throw it } + } + }, + outputProvider = + object : PdfOutputProvider { + override fun newFilePath(name: String) = path + + override fun exists(name: String) = false + + override fun listFiles() = emptyList() + + override fun deleteReport(path: String) { + deletedPaths.add(path) + } + }, + launcher = + object : PdfReportLauncher { + override fun share(path: String) { + sharedPath = path + } + + override fun open(path: String) { + openedPath = path + } + }, + coroutineDispatcher = Dispatchers.Unconfined, + ) + + private companion object { + val document = + SubmissionPdfDocument( + header = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ), + qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), + footer = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "Name", + userEmail = "user@example.com", + ), + table = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Loi", + rows = emptyList(), + ), + ) + + val request = + PdfExportService.Request(document = document, qrContent = null, fileName = "report.pdf") + } +} From 89c647b4aa55005ac2fd840f05cf647ba9f4c7e8 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 1 Jun 2026 10:39:00 +0200 Subject: [PATCH 12/50] fix file names not handling all types of characters --- .../feature/pdf/mapper/LoiReportMapper.kt | 17 +++++++++-- .../feature/pdf/mapper/LoiReportMapperTest.kt | 30 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt index c904d739af..418ff725aa 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -62,7 +62,7 @@ class LoiReportMapper( .map { it.filter(::isSafeFileChar) } .filter { it.isNotBlank() } .joinToString("_") - .take(200) + .take(100) return PdfExportService.Request( document = document, @@ -103,6 +103,19 @@ class LoiReportMapper( } } + /** + * Allows Unicode letters, digits, combining marks, hyphens, and underscores while rejecting + * characters reserved or unsafe in file paths. + */ private fun isSafeFileChar(c: Char): Boolean = - c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-" + c.isLetterOrDigit() || c.category in COMBINING_MARK_CATEGORIES || c in "_-" + + private companion object { + val COMBINING_MARK_CATEGORIES = + setOf( + CharCategory.NON_SPACING_MARK, + CharCategory.COMBINING_SPACING_MARK, + CharCategory.ENCLOSING_MARK, + ) + } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index 379d2068d6..3f3939c6c8 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -87,24 +87,42 @@ class LoiReportMapperTest { } @Test - fun `file name strips non-ASCII characters`() = runTest { + fun `file name preserves non-Latin characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "ເພີ່ມຈຸດສຳຫຼວດ", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "แบบสำรวจ", userName = "テスト"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("แบบสำรวจ_ເພີ່ມຈຸດສຳຫຼວດ_テスト_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name sanitizes reserved characters and preserves accented Latin letters`() = runTest { val request = mapper.map( loiReport = FakeDataGenerator.newLoiReport( loiName = "ß", submissionDetails = - FakeDataGenerator.newSubmissionDetails(surveyName = "café", userName = "José"), + FakeDataGenerator.newSubmissionDetails( + surveyName = "Café/São/José", + userName = "Test?", + ), ), submission = FakeDataGenerator.newSubmission(), ) - // "café" -> "caf", "ß" -> "", "José" -> "Jos". - assertEquals("caf_Jos_$timestampSegment", request!!.fileName) + assertEquals("CaféSãoJosé_ß_Test_$timestampSegment", request!!.fileName) } @Test - fun `file name is capped at 200 characters`() = runTest { + fun `file name is capped at 100 characters`() = runTest { val request = mapper.map( loiReport = @@ -116,7 +134,7 @@ class LoiReportMapperTest { submission = FakeDataGenerator.newSubmission(), ) - assertEquals(200, request!!.fileName.length) + assertEquals(100, request!!.fileName.length) } @Test From 928e8029540634f56c1fc50bf481149f5d8372d8 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:41:58 +0200 Subject: [PATCH 13/50] add common layout components to render --- .../feature/pdf/render/PdfConfig.kt | 51 ++++++ .../feature/pdf/render/PdfGeometry.kt | 40 +++++ .../pdf/render/components/PageFooterLayout.kt | 52 ++++++ .../pdf/render/components/PageHeaderLayout.kt | 64 +++++++ .../pdf/render/components/QrBlockLayout.kt | 52 ++++++ .../pdf/render/components/TableRowLayout.kt | 90 ++++++++++ .../render/components/PageFooterLayoutTest.kt | 91 ++++++++++ .../render/components/PageHeaderLayoutTest.kt | 107 ++++++++++++ .../render/components/QrBlockLayoutTest.kt | 74 ++++++++ .../render/components/TableRowLayoutTest.kt | 163 ++++++++++++++++++ 10 files changed, 784 insertions(+) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt new file mode 100644 index 0000000000..a502c8dccd --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** + * 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). + */ +internal object PdfConfig { + const val PAGE_WIDTH = 595 // A4 page width + const val PAGE_HEIGHT = 842 // A4 page height + const val MARGIN = 40 + const val TITLE_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_TASK_LABEL_RATIO = 0.35f + const val CELL_PADDING = 6 + const val BORDER_WIDTH = 0.5f + const val PHOTO_MAX_HEIGHT_RATIO = 0.35f + const val HEADER_BOTTOM_GAP = 28f + const val FOOTER_TOP_GAP = 28f + const val MAX_HEADER_VALUE_LINES = 1 + const val MAX_FOOTER_LINES = 1 + const val IMAGE_RENDER_DPI = 300f + const val USABLE_WIDTH = PAGE_WIDTH - 2 * MARGIN + const val TABLE_TASK_COLUMN_WIDTH = (USABLE_WIDTH * TABLE_TASK_LABEL_RATIO).toInt() + const val TABLE_TASK_TEXT_WIDTH = TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val TABLE_ANSWER_TEXT_WIDTH = USABLE_WIDTH - TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val PHOTO_MAX_HEIGHT = ((PAGE_HEIGHT - 2 * MARGIN) * PHOTO_MAX_HEIGHT_RATIO).toInt() + const val PAGE_NUMBER_BAND_WIDTH = 60 + const val FOOTER_PAGE_NUMBER_GAP = 8 + const val FOOTER_TEXT_MAX_WIDTH = USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH - FOOTER_PAGE_NUMBER_GAP +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt new file mode 100644 index 0000000000..40b0fc8ce0 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.math.roundToInt + +internal fun fitInside(width: Int, height: Int, maxWidth: Int, maxHeight: Int): PdfItemSize { + val scale = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height, 1f) + return PdfItemSize(width * scale, height * scale) +} + +internal fun pointsToRenderPixels(points: Float): Int = + // 1 point = 1/72 inch (standard PDF user-space unit) + (points / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt() + +internal data class PdfItemSize(val width: Float, val height: Float) + +internal data class PdfOffset(val x: Float, val y: Float) + +/** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ +internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { + val right: Float + get() = x + width + + val bottom: Float + get() = y + height +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt new file mode 100644 index 0000000000..adf5b3976d --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page footer with separate left and right slots. + * + * @param footerTextOffset The top-left position where the footer text begins. + * @param footerMaxWidth The maximum width available for the footer text. + * @param pageNumberOffset The top-left position where the page number begins. + * @param pageNumberMaxWidth The maximum width available for the page number + */ +internal data class PageFooterLayout( + val footerTextOffset: PdfOffset, + val footerMaxWidth: Int, + val pageNumberOffset: PdfOffset, + val pageNumberMaxWidth: Int, +) { + companion object { + fun compute(footerHeight: Float): PageFooterLayout { + val top = PAGE_HEIGHT - MARGIN - footerHeight + val left = MARGIN.toFloat() + val pageNumberLeft = left + USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH + return PageFooterLayout( + footerTextOffset = PdfOffset(left, top), + footerMaxWidth = FOOTER_TEXT_MAX_WIDTH, + pageNumberOffset = PdfOffset(pageNumberLeft, top), + pageNumberMaxWidth = PAGE_NUMBER_BAND_WIDTH, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt new file mode 100644 index 0000000000..c02bbb6025 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_BOTTOM_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_COLUMN_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page header with three slots. Assumes uniform typography across + * columns. + * + * @param leftColumn Label and value positions for the left column. + * @param centerColumn Label and value positions for the center column. + * @param rightTextOffset The position where the right-aligned value begins . + * @param nextCursorY The Y position where the cursor should be positioned after the header. + */ +internal data class PageHeaderLayout( + val leftColumn: Column, + val centerColumn: Column, + val rightTextOffset: PdfOffset, + val nextCursorY: Float, +) { + companion object { + const val COLUMN_WIDTH: Int = (USABLE_WIDTH - 2 * HEADER_COLUMN_GAP) / 3 + const val LEFT_X: Float = MARGIN.toFloat() + const val CENTER_X: Float = LEFT_X + COLUMN_WIDTH + HEADER_COLUMN_GAP + const val RIGHT_X: Float = LEFT_X + 2 * (COLUMN_WIDTH + HEADER_COLUMN_GAP) + + fun compute(top: Float, labelHeight: Float, valueHeight: Float): PageHeaderLayout { + val columnBottom = top + labelHeight + LINE_SPACING + valueHeight + return PageHeaderLayout( + leftColumn = column(LEFT_X, top, labelHeight), + centerColumn = column(CENTER_X, top, labelHeight), + rightTextOffset = PdfOffset(RIGHT_X, top), + nextCursorY = columnBottom + HEADER_BOTTOM_GAP, + ) + } + + private fun column(x: Float, top: Float, labelHeight: Float) = + Column( + labelOffset = PdfOffset(x, top), + valueOffset = PdfOffset(x, top + labelHeight + LINE_SPACING), + ) + } + + data class Column(val labelOffset: PdfOffset, val valueOffset: PdfOffset) +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt new file mode 100644 index 0000000000..aafb3b964a --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.QR_SIZE +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect + +/** + * Pre-computed layout for the right-aligned QR code block with its caption. Compute should only be + * called when a QR image is available; the caption is meaningless without it. + * + * @param qrFrame Position and size of the QR image. + * @param captionOffset Top-left position of the caption text (centered under the QR). + * @param captionMaxWidth Maximum width for the caption. + * @param nextCursorY Cursor Y position after this block. + */ +internal data class QrBlockLayout( + val qrFrame: PdfRect, + val captionOffset: PdfOffset, + val captionMaxWidth: Int, + val nextCursorY: Float, +) { + companion object { + fun compute(top: Float, captionHeight: Float): QrBlockLayout { + val x = (PAGE_WIDTH - MARGIN - QR_SIZE).toFloat() + val captionTop = top + QR_SIZE + LINE_SPACING + return QrBlockLayout( + qrFrame = PdfRect(x, top, QR_SIZE.toFloat(), QR_SIZE.toFloat()), + captionOffset = PdfOffset(x, captionTop), + captionMaxWidth = QR_SIZE, + nextCursorY = captionTop + captionHeight + LINE_SPACING * 2, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt new file mode 100644 index 0000000000..cf7e2de67c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect + +/** + * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell + * may contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. + */ +internal data class TableRowLayout( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, +) { + companion object { + fun totalHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun compute( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): TableRowLayout { + val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val midX = left + TABLE_TASK_COLUMN_WIDTH + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return TableRowLayout( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = left + USABLE_WIDTH, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + ) + } + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt new file mode 100644 index 0000000000..e15a3f4c36 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig + +class PageFooterLayoutTest { + + private val margin = PdfConfig.MARGIN.toFloat() + private val pageHeight = PdfConfig.PAGE_HEIGHT + private val usableWidth = PdfConfig.USABLE_WIDTH + private val pageNumberBand = PdfConfig.PAGE_NUMBER_BAND_WIDTH + private val footerTextMaxWidth = PdfConfig.FOOTER_TEXT_MAX_WIDTH + + @Test + fun `footer text anchors against the bottom margin`() { + val footerHeight = 12f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(margin, layout.footerTextOffset.x) + assertEquals(pageHeight - margin - footerHeight, layout.footerTextOffset.y) + } + + @Test + fun `page number sits in the right-side slot on the same baseline as the footer text`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(margin + usableWidth - pageNumberBand, layout.pageNumberOffset.x) + assertEquals(layout.footerTextOffset.y, layout.pageNumberOffset.y) + } + + @Test + fun `slot widths match their respective configuration constants`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(footerTextMaxWidth, layout.footerMaxWidth) + assertEquals(pageNumberBand, layout.pageNumberMaxWidth) + } + + @Test + fun `footer and page number slots do not overlap`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + val footerRight = layout.footerTextOffset.x + layout.footerMaxWidth + assertTrue(footerRight <= layout.pageNumberOffset.x) + } + + @Test + fun `page number band ends exactly at the right page margin`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals( + margin + usableWidth, + layout.pageNumberOffset.x + layout.pageNumberMaxWidth, + "Page number band's right edge must align with the right page margin", + ) + } + + @Test + fun `taller footer pushes the baseline higher up the page`() { + val short = PageFooterLayout.compute(footerHeight = 12f) + val tall = PageFooterLayout.compute(footerHeight = 30f) + + assertTrue(tall.footerTextOffset.y < short.footerTextOffset.y) + assertEquals(short.footerTextOffset.y - 18f, tall.footerTextOffset.y) + } + + @Test + fun `footer height plus baseline plus bottom margin equals page height`() { + val footerHeight = 18f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt new file mode 100644 index 0000000000..792fa0e62d --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.text.compareTo +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfOffset + +class PageHeaderLayoutTest { + + private val lineSpacing = PdfConfig.LINE_SPACING + private val headerBottomGap = PdfConfig.HEADER_BOTTOM_GAP + private val headerColumnGap = PdfConfig.HEADER_COLUMN_GAP + private val usableWidth = PdfConfig.USABLE_WIDTH + + @Test + fun `column X positions span the usable width with gaps between them`() { + val left = PageHeaderLayout.LEFT_X + val center = PageHeaderLayout.CENTER_X + val right = PageHeaderLayout.RIGHT_X + val width = PageHeaderLayout.COLUMN_WIDTH + + assertTrue(left < center) + assertTrue(center < right) + assertEquals(headerColumnGap.toFloat(), center - (left + width)) + assertEquals(headerColumnGap.toFloat(), right - (center + width)) + assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + } + + @Test + fun `compute places survey column labels and values at LEFT_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.LEFT_X, 0f), layout.leftColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.LEFT_X, 10f + lineSpacing), + layout.leftColumn.valueOffset, + ) + } + + @Test + fun `compute places job column labels and values at CENTER_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.CENTER_X, 0f), layout.centerColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.CENTER_X, 10f + lineSpacing), + layout.centerColumn.valueOffset, + ) + } + + @Test + fun `compute places timestamp at RIGHT_X with the same top as labels`() { + val layout = PageHeaderLayout.compute(top = 50f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.RIGHT_X, 50f), layout.rightTextOffset) + assertEquals(layout.leftColumn.labelOffset.y, layout.rightTextOffset.y) + assertEquals(layout.centerColumn.labelOffset.y, layout.rightTextOffset.y) + } + + @Test + fun `value sits below its label by exactly line spacing`() { + val labelHeight = 12f + val layout = PageHeaderLayout.compute(top = 30f, labelHeight = labelHeight, valueHeight = 14f) + + val survey = layout.leftColumn + assertEquals(labelHeight + lineSpacing, survey.valueOffset.y - survey.labelOffset.y) + } + + @Test + fun `nextCursorY accounts for label, line spacing, value, and header bottom gap`() { + val top = 40f + val labelHeight = 10f + val valueHeight = 14f + + val layout = PageHeaderLayout.compute(top = top, labelHeight, valueHeight) + + assertEquals( + top + labelHeight + lineSpacing + valueHeight + headerBottomGap, + layout.nextCursorY, + ) + } + + @Test + fun `all three columns share the same label baseline and value baseline`() { + val layout = PageHeaderLayout.compute(top = 100f, labelHeight = 12f, valueHeight = 16f) + + assertEquals(layout.leftColumn.labelOffset.y, layout.centerColumn.labelOffset.y) + assertEquals(layout.leftColumn.valueOffset.y, layout.centerColumn.valueOffset.y) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt new file mode 100644 index 0000000000..befaa6a137 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.groundplatform.feature.pdf.render.PdfConfig + +class QrBlockLayoutTest { + + private val margin = PdfConfig.MARGIN + private val pageWidth = PdfConfig.PAGE_WIDTH + private val qrSize = PdfConfig.QR_SIZE + private val lineSpacing = PdfConfig.LINE_SPACING + + private val expectedX = (pageWidth - margin - qrSize).toFloat() + + @Test + fun `QR frame is a square anchored at the right margin`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(expectedX, layout.qrFrame.x) + assertEquals(0f, layout.qrFrame.y) + assertEquals(qrSize.toFloat(), layout.qrFrame.width) + assertEquals(qrSize.toFloat(), layout.qrFrame.height) + assertEquals((pageWidth - margin).toFloat(), layout.qrFrame.right) + } + + @Test + fun `caption sits directly below the QR with line spacing between them`() { + val top = 100f + val layout = QrBlockLayout.compute(top = top, captionHeight = 10f) + + assertEquals(expectedX, layout.captionOffset.x) + assertEquals(top + qrSize + lineSpacing, layout.captionOffset.y) + } + + @Test + fun `caption maxWidth equals QR size so it stays under the QR image`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(qrSize, layout.captionMaxWidth) + } + + @Test + fun `caption shares its X with the QR frame`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(layout.qrFrame.x, layout.captionOffset.x) + } + + @Test + fun `nextCursorY accounts for QR, caption, and trailing spacing`() { + val top = 50f + val captionHeight = 14f + val layout = QrBlockLayout.compute(top = top, captionHeight = captionHeight) + + val expectedCaptionTop = top + qrSize + lineSpacing + assertEquals(expectedCaptionTop + captionHeight + lineSpacing * 2, layout.nextCursorY) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt new file mode 100644 index 0000000000..f8b54c350a --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset + +class TableRowLayoutTest { + + private val cellPadding = PdfConfig.CELL_PADDING.toFloat() + private val lineSpacing = PdfConfig.LINE_SPACING + private val margin = PdfConfig.MARGIN.toFloat() + private val usableWidth = PdfConfig.USABLE_WIDTH + private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH + + @Test + fun `totalHeight with only left text returns left height plus padding`() { + val height = + TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + + assertEquals(30f + 2 * cellPadding, height) + } + + @Test + fun `totalHeight picks the taller content height`() { + val tallerLeft = + TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + val tallerRight = + TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + val tallerImageRight = + TableRowLayout.totalHeight( + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(50f + 2 * cellPadding, tallerLeft) + assertEquals(60f + 2 * cellPadding, tallerRight) + assertEquals(80f + 2 * cellPadding, tallerImageRight) + } + + @Test + fun `totalHeight with both right text and image stacks them with line spacing`() { + val height = + TableRowLayout.totalHeight( + leftTextHeight = 10f, + rightTextHeight = 20f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(20f + lineSpacing + 80f + 2 * cellPadding, height) + } + + @Test + fun `compute always places left text at the row's top-left content area`() { + val layout = + TableRowLayout.compute( + rowTop = 100f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(PdfOffset(margin + cellPadding, 100f + cellPadding), layout.leftTextOffset) + } + + @Test + fun `compute returns null right offsets when right cell has no content`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertNull(layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places right text at the right cell's top`() { + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + assertEquals(PdfOffset(rightCellX, 50f + cellPadding), layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places image at the right cell's top`() { + val imageSize = PdfItemSize(width = 80f, height = 60f) + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = imageSize, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + val frame = assertNotNull(layout.rightImageFrame) + assertNull(layout.rightTextOffset) + with(frame) { + assertEquals(rightCellX, x) + assertEquals(50f + cellPadding, y) + assertEquals(imageSize.width, width) + assertEquals(imageSize.height, height) + } + } + + @Test + fun `compute sets row bounds and divider from page geometry`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(margin, layout.leftRowX) + assertEquals(margin + usableWidth, layout.rightRowX) + assertEquals(margin + taskColumnWidth, layout.columnDividerX) + assertTrue(layout.leftRowX < layout.columnDividerX) + assertTrue(layout.columnDividerX < layout.rightRowX) + } + + @Test + fun `compute totalHeight matches the static helper`() { + val left = 30f + val right = 20f + val image = PdfItemSize(width = 80f, height = 60f) + val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + + assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + } +} From f204be02d9799787ad3bc58f905893412d2aec6e Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:59:54 +0200 Subject: [PATCH 14/50] update QrCodeGenerator to provide bitmap+logo for PDF documents --- .../qrcode/QrCodeGenerator.android.kt | 2 +- .../ui/components/qrcode/GroundQrCode.kt | 29 +------- .../ui/components/qrcode/QrCodeGenerator.kt | 68 ++++++++++++++++++- .../components/qrcode/QrCodeGenerator.ios.kt | 2 +- 4 files changed, 72 insertions(+), 29 deletions(-) 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/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt index 23b076a7c8..0b7f1783e4 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,29 +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. - * - * The composable is intentionally generic, it accepts any string payload, making it reusable for - * GeoJSON, URLs, or any other data that fits within QR code capacity limits. - */ @Composable fun GroundQrCode( modifier: Modifier = Modifier, @@ -80,12 +57,12 @@ fun GroundQrCode( centerLogoPainter: Painter?, footer: String, ) { - val contentBytes = remember(content) { content.encodeToByteArray().size } - val showLogo = centerLogoPainter != null && contentBytes <= MAX_QR_BYTES_WITH_LOGO + val fitsLogo = remember(content) { fitsLogoCapacity(content) } + val showLogo = centerLogoPainter != null && fitsLogo 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..94c0a829a9 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,74 @@ */ 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 +/** + * PDF document has more space to display the QR code, so we can use a larger fraction + */ +const val PDF_LOGO_SIZE_FRACTION = 0.25f + +/** + * 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 || !fitsLogoCapacity(content)) { + encodeQrBitmap(content, useHighEcc = false) + } else encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction) + +internal fun fitsLogoCapacity(content: String): Boolean = + content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO + + +/** 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/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() From 6a4f8a7aeb3d7f9163f72a65e0118ad946284efb Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:00:55 +0200 Subject: [PATCH 15/50] add android implementations for PDF platform interfaces --- feature/pdf/build.gradle.kts | 17 ++ .../feature/pdf/AndroidPdfImageProvider.kt | 163 ++++++++++ .../feature/pdf/AndroidPdfOutputProvider.kt | 43 +++ .../feature/pdf/AndroidPdfRenderer.kt | 72 +++++ .../feature/pdf/AndroidPdfReportLauncher.kt | 59 ++++ .../feature/pdf/render/DocumentPdfCanvas.kt | 55 ++++ .../feature/pdf/render/PdfCanvas.kt | 31 ++ .../feature/pdf/render/PdfTextPaints.kt | 24 ++ .../feature/pdf/render/PdfWriter.kt | 279 ++++++++++++++++++ .../feature/pdf/render/PdfCursor.kt | 46 +++ .../feature/pdf/render/PdfPageController.kt | 64 ++++ 11 files changed, 853 insertions(+) create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index 953e7adfce..d75697daac 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -20,6 +20,8 @@ plugins { } kotlin { + jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) + androidLibrary { namespace = "org.groundplatform.feature.pdf" compileSdk { version = release(libs.versions.androidCompileSdk.get().toInt()) } @@ -43,6 +45,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.ui) } } @@ -53,5 +56,19 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + + androidMain { + dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + } + } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + } } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt new file mode 100644 index 0000000000..95bf67e109 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import 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.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import kotlin.math.roundToInt +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.pointsToRenderPixels +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap + +/** + * Android implementation of [PdfImageProvider]. + * + * Bitmaps are decoded and scaled to their final on-page pixel size here so the renderer can draw + * them as-is without any further bitmap work. + * + * @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 { + + private val qrMaxPx = pointsToRenderPixels(PdfConfig.QR_SIZE.toFloat()) + private val photoMaxWidthPx = pointsToRenderPixels(PdfConfig.TABLE_ANSWER_TEXT_WIDTH.toFloat()) + private val photoMaxHeightPx = pointsToRenderPixels(PdfConfig.PHOTO_MAX_HEIGHT.toFloat()) + + 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() + .downscaledTo(qrMaxPx, qrMaxPx) + } + .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 decoded = runCatching { decodeSubsampled(file.absolutePath) }.getOrNull() ?: return null + val oriented = applyExifOrientation(file, decoded) + return oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + } + + /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ + private fun decodeSubsampled(path: String): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + // Orientation isn't known yet, so size against the larger target on both axes to be safe. + val target = maxOf(photoMaxWidthPx, photoMaxHeightPx) + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, target) + } + return BitmapFactory.decodeFile(path, options) + } + + /** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ + private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + var sampleSize = 1 + while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return sampleSize + } + + /** + * 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 + } +} + +/** + * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and + * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. + */ +private fun Bitmap.downscaledTo(maxWidthPx: Int, maxHeightPx: Int): Bitmap { + if (width <= maxWidthPx && height <= maxHeightPx) return this + val ratio = minOf(maxWidthPx.toFloat() / width, maxHeightPx.toFloat() / height) + val targetWidth = (width * ratio).roundToInt().coerceAtLeast(1) + val targetHeight = (height * ratio).roundToInt().coerceAtLeast(1) + + var current = this + var w = width + var h = height + while (w / 2 >= targetWidth && h / 2 >= targetHeight) { + w /= 2 + h /= 2 + val halved = current.scale(w, h) + if (current !== this) current.recycle() + current = halved + } + val result = current.scale(targetWidth, targetHeight) + if (current !== this) current.recycle() + return result +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..1ae8029940 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/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.feature.pdf + +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/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt new file mode 100644 index 0000000000..d237510484 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.graphics.pdf.PdfDocument +import java.io.File +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.DocumentPdfCanvas +import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas +import org.groundplatform.feature.pdf.render.PdfCanvas +import org.groundplatform.feature.pdf.render.PdfWriter +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just + * counts pages. + */ +class AndroidPdfRenderer : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + // Measurement first so the footer can show "page/total" + val totalPages = measurePageCount(document, images) + val pdf = PdfDocument() + try { + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages).draw(document) + File(outputPath).outputStream().use { pdf.writeTo(it) } + } finally { + pdf.close() + } + } + + private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = + writer(document, images, MeasurementPdfCanvas, totalPages = null).draw(document).pageCount + + private fun writer( + document: SubmissionPdfDocument, + images: PdfImageSet, + pdfCanvas: PdfCanvas, + totalPages: Int?, + ): PdfWriter = + PdfWriter( + pdfCanvas = pdfCanvas, + images = images, + header = document.header, + footer = document.footer, + totalPages = totalPages, + ) + + private fun PdfWriter.draw(document: SubmissionPdfDocument): PdfWriter = apply { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..80f294da51 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** 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) + } + + companion object { + private const val PDF_MIME_TYPE = "application/pdf" + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt new file mode 100644 index 0000000000..5bbebca4f0 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -0,0 +1,55 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import androidx.core.graphics.withTranslation +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** + * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * to arrive at their on-page pixel size; the canvas does no further scaling. + */ +internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { + private var currentPage: PdfDocument.Page? = null + + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = PdfConfig.BORDER_WIDTH + isAntiAlias = true + } + + private val smoothImagePaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + override fun startPage(pageNumber: Int) { + val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + currentPage = pdf.startPage(info) + } + + override fun finishPage() { + currentPage?.also { pdf.finishPage(it) } + currentPage = null + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + canvas().drawBitmap(image.bitmap, null, frame, if (smoothScaling) smoothImagePaint else null) + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + canvas().drawLine(x1, y1, x2, y2, strokePaint) + } + + private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt new file mode 100644 index 0000000000..3fa449022e --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -0,0 +1,31 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** Abstraction for drawing onto a PDF page. */ +internal interface PdfCanvas { + fun startPage(pageNumber: Int) + + fun finishPage() + + fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) + + fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) + + fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) +} + +/** Used during the page-counting phase. Drops every drawing call. */ +internal object MeasurementPdfCanvas : PdfCanvas { + override fun startPage(pageNumber: Int) = Unit + + override fun finishPage() = Unit + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) = Unit + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt new file mode 100644 index 0000000000..f60a0f41a9 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -0,0 +1,24 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.PdfConfig.BODY_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.CAPTION_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.TITLE_SIZE + +internal class PdfTextPaints { + val title: TextPaint = textPaint(TITLE_SIZE, bold = false) + val body: TextPaint = textPaint(BODY_SIZE, bold = false) + val metaLabel: TextPaint = textPaint(CAPTION_SIZE, bold = true, textColor = Color.GRAY) + val meta: TextPaint = textPaint(CAPTION_SIZE, bold = false, textColor = Color.GRAY) + val caption: TextPaint = textPaint(CAPTION_SIZE, bold = false) + + private fun textPaint(size: Float, bold: Boolean, textColor: Int = Color.BLACK): TextPaint = + TextPaint().apply { + textSize = size + color = textColor + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt new file mode 100644 index 0000000000..dabba56a6f --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -0,0 +1,279 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.StyleSpan +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.QR_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.components.PageFooterLayout +import org.groundplatform.feature.pdf.render.components.PageHeaderLayout +import org.groundplatform.feature.pdf.render.components.QrBlockLayout +import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [PdfCursor], shared paints) shared by all + * sections. + */ +internal class PdfWriter( + private val pdfCanvas: PdfCanvas, + private val images: PdfImageSet, + private val totalPages: Int? = null, + private val header: Header, + footer: Footer, +) : PdfPageController.PageLifecycle { + private val paints = PdfTextPaints() + + private val cursor = PdfCursor() + private val pageController = PdfPageController(cursor, this) + + private var currentTableTopY: Float? = null + + private val footerLayout: StaticLayout + + init { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + footerLayout = + staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP + } + + val pageCount: Int + get() = pageController.pageCount + + override fun onPageStarted(pageNumber: Int) { + pdfCanvas.startPage(pageNumber) + drawPageHeader() + } + + override fun onPageEnding(pageNumber: Int) { + flushTableDivider() + drawPageFooter() + pdfCanvas.finishPage() + } + + fun drawQrBlock(block: QrBlock) { + val qr = images[PdfImageSet.ImageRef.Qr] ?: return + pageController.ensurePage() + val captionLayout = + staticLayout(block.scanCaption, paints.caption, QR_SIZE, Layout.Alignment.ALIGN_CENTER) + val layout = + QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) + drawImage(qr, layout.qrFrame, smoothScaling = false) + drawStaticLayoutAt(captionLayout, layout.captionOffset) + cursor.moveTo(layout.nextCursorY) + } + + fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + pageController.ensurePage() + val x = MARGIN.toFloat() + cursor.advance(LINE_SPACING * 2) + val label = + SpannableString("${table.submissionLabel}: ${table.loiName}").apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + table.submissionLabel.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) + cursor.advance(LINE_SPACING) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> + drawTableRow( + questionText = row.question, + answerText = answer.lines.joinToString("\n"), + photo = null, + ) + is Answer.Photo -> + drawTableRow( + questionText = row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + flushTableDivider() + } + + fun finalizePage() { + pageController.finalizePage() + } + + private fun drawPageHeader() { + val columnWidth = PageHeaderLayout.COLUMN_WIDTH + val surveyLabel = staticLayout(header.surveyLabel, paints.metaLabel, columnWidth) + val surveyValue = + staticLayout(header.surveyName, paints.meta, columnWidth, maxLines = MAX_HEADER_VALUE_LINES) + val jobLabel = + staticLayout(header.jobLabel, paints.metaLabel, columnWidth, Layout.Alignment.ALIGN_CENTER) + val jobValue = + staticLayout( + header.jobName, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = MAX_HEADER_VALUE_LINES, + ) + val timestamp = + staticLayout( + header.timestamp, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = MAX_HEADER_VALUE_LINES, + ) + + val layout = + PageHeaderLayout.compute( + top = cursor.y, + labelHeight = surveyLabel.height.toFloat(), + valueHeight = surveyValue.height.toFloat(), + ) + + drawStaticLayoutAt(surveyLabel, layout.leftColumn.labelOffset) + drawStaticLayoutAt(surveyValue, layout.leftColumn.valueOffset) + drawStaticLayoutAt(jobLabel, layout.centerColumn.labelOffset) + drawStaticLayoutAt(jobValue, layout.centerColumn.valueOffset) + drawStaticLayoutAt(timestamp, layout.rightTextOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawPageFooter() { + val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) + drawStaticLayoutAt(footerLayout, layout.footerTextOffset) + totalPages?.let { total -> + drawText( + text = "${pageController.pageCount}/$total", + x = layout.pageNumberOffset.x, + y = layout.pageNumberOffset.y, + maxWidth = layout.pageNumberMaxWidth, + paint = paints.meta, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + } + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, paints.body, PdfConfig.TABLE_TASK_TEXT_WIDTH) + val answerLayout = + if (answerText.isEmpty()) null + else staticLayout(answerText, paints.body, PdfConfig.TABLE_ANSWER_TEXT_WIDTH) + val photoSize = photo?.let { + fitInside(it.width, it.height, PdfConfig.TABLE_ANSWER_TEXT_WIDTH, PHOTO_MAX_HEIGHT) + } + + val questionHeight = questionLayout.height.toFloat() + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + pageController.newPageIfShort( + TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) + ) + val rowLayout = + TableRowLayout.compute( + rowTop = cursor.y, + leftTextHeight = questionHeight, + rightTextHeight = answerHeight, + rightImageSize = photoSize, + ) + + if (currentTableTopY == null) { + currentTableTopY = cursor.y + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) + if (answerLayout != null && rowLayout.rightTextOffset != null) { + drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) + } + if (photo != null && rowLayout.rightImageFrame != null) { + drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) + } + cursor.advance(rowLayout.totalHeight) + + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + private fun flushTableDivider() { + val top = currentTableTopY ?: return + val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() + pdfCanvas.drawLine(midX, top, midX, cursor.y) + currentTableTopY = null + } + + /** + * Lays out [text] and draws it at ([x], [y]). + * + * @return the Y just below the drawn text. + */ + private fun drawText( + text: CharSequence, + 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) + pdfCanvas.drawStaticLayout(layout, x, y) + return y + layout.height + } + + private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = + pdfCanvas.drawStaticLayout(layout, offset.x, offset.y) + + private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = + pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + + /** + * 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: CharSequence, + 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() +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt new file mode 100644 index 0000000000..2c67ea1f02 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class PdfCursor( + private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, + private val margin: Int = PdfConfig.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 + + val isAtPageTop: Boolean + get() = y == margin.toFloat() + + 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt new file mode 100644 index 0000000000..b256481cb7 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** + * Platform-agnostic page state machine for PDF rendering. Delegates the actual page allocation and + * drawing to a platform-specific [PageLifecycle] implementation. + */ +internal class PdfPageController( + private val cursor: PdfCursor, + private val lifecycle: PageLifecycle, +) { + interface PageLifecycle { + /** Called after a new page has been allocated. The header should be drawn here. */ + fun onPageStarted(pageNumber: Int) + + /** Called before the page is closed. The footer and per-page flush should happen here. */ + fun onPageEnding(pageNumber: Int) + } + + private var pageIndex = 0 + private var pageOpen = false + + /** Number of pages emitted so far. Equals the current page number while a page is open. */ + val pageCount: Int + get() = pageIndex + + fun ensurePage() { + if (!pageOpen) beginPage() + } + + fun newPageIfShort(spaceNeeded: Float) { + ensurePage() + if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return + finalizePage() + beginPage() + } + + fun finalizePage() { + if (!pageOpen) return + lifecycle.onPageEnding(pageIndex) + pageOpen = false + } + + private fun beginPage() { + pageIndex++ + pageOpen = true + cursor.reset() + lifecycle.onPageStarted(pageIndex) + } +} From 49db9bc54778528b0ae79685dae08f4a8a321ee4 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:01:20 +0200 Subject: [PATCH 16/50] add unit tests for PdfCursor and PdfPageController --- .../feature/pdf/render/PdfCursorTest.kt | 123 ++++++++++++ .../pdf/render/PdfPageControllerTest.kt | 176 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 301 insertions(+) create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt new file mode 100644 index 0000000000..06bf9f85d9 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfCursorTest { + + @Test + fun `fresh cursor starts at the top margin`() { + val cursor = PdfCursor() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `advance moves the cursor down by the given delta`() { + val cursor = PdfCursor() + val start = cursor.y + + cursor.advance(75f) + + assertEquals(start + 75f, cursor.y) + assertFalse(cursor.isAtPageTop) + } + + @Test + fun `moveTo sets the cursor to an absolute Y`() { + val cursor = PdfCursor() + + cursor.moveTo(400f) + + assertEquals(400f, cursor.y) + } + + @Test + fun `reset returns the cursor to the top margin`() { + val cursor = PdfCursor() + cursor.advance(300f) + + cursor.reset() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `isAtPageTop reflects whether Y matches the top margin`() { + val cursor = PdfCursor() + assertTrue(cursor.isAtPageTop) + + cursor.advance(1f) + assertFalse(cursor.isAtPageTop) + + cursor.moveTo(PdfConfig.MARGIN.toFloat()) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `fits returns true when there is room above the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertTrue(cursor.fits(available.toFloat())) + assertTrue(cursor.fits(10f)) + } + + @Test + fun `fits returns false when the requested height overflows the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertFalse(cursor.fits(available + 1f)) + } + + @Test + fun `fits subtracts the footer reserve from the available space`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + cursor.footerReserve = 50f + + assertTrue(cursor.fits(available - 50f)) + assertFalse(cursor.fits(available - 49f)) + } + + @Test + fun `fits depends on the current Y position`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + cursor.advance(100f) + + assertTrue(cursor.fits(available - 100f)) + assertFalse(cursor.fits(available - 99f)) + } + + @Test + fun `custom page height and margin are respected`() { + val cursor = PdfCursor(pageHeight = 200, margin = 10) + + assertEquals(10f, cursor.y) + // Usable height = 200 - 2*10 = 180. + assertTrue(cursor.fits(180f)) + assertFalse(cursor.fits(181f)) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt new file mode 100644 index 0000000000..dccda1cf90 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfPageControllerTest { + + private val lifecycle = + object : PdfPageController.PageLifecycle { + val events: MutableList = mutableListOf() + + override fun onPageStarted(pageNumber: Int) { + events += PageEvent.Started(pageNumber) + } + + override fun onPageEnding(pageNumber: Int) { + events += PageEvent.Ending(pageNumber) + } + } + private val cursor = PdfCursor() + private val controller = PdfPageController(cursor, lifecycle) + + @Test + fun `Should have zero pages at the start`() { + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `ensurePage starts the a page`() { + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `ensurePage is idempotent while the page is open`() { + controller.ensurePage() + controller.ensurePage() + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `finalizePage does nothing if there is no page open`() { + controller.finalizePage() + + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `finalizePage does nothing when the page is already closed`() { + controller.ensurePage() + controller.finalizePage() + controller.finalizePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `ensurePage followed by finalize emits start and end events`() { + controller.ensurePage() + controller.finalizePage() + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort starts a page if there is none open`() { + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does not emit more pages if impossible to fit content in a new page`() { + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does nothing if the content fits in the current page`() { + controller.ensurePage() + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `newPageIfShort starts a new page if the content overflows`() { + controller.ensurePage() + cursor.advance(100f) + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(2, controller.pageCount) + assertEquals(listOf(PageEvent.Ending(1), PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `newPageIfShort should set the cursor at the start of the new page`() { + controller.ensurePage() + cursor.advance(500f) + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + } + + @Test + fun `Adding multiple pages emits the correct start and end events`() { + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + controller.finalizePage() + + assertEquals(3, controller.pageCount) + assertEquals( + listOf( + PageEvent.Started(1), + PageEvent.Ending(1), + PageEvent.Started(2), + PageEvent.Ending(2), + PageEvent.Started(3), + PageEvent.Ending(3), + ), + lifecycle.events, + ) + } + + @Test + fun `pageCount reflects the current page number while the page is open`() { + controller.ensurePage() + assertEquals(1, controller.pageCount) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + assertEquals(2, controller.pageCount) + } + + private sealed interface PageEvent { + data class Started(val pageNumber: Int) : PageEvent + + data class Ending(val pageNumber: Int) : PageEvent + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca8d8fd68b..cef9e0bc28 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.14.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 bec48eca6d30a676da64feb378a1179776bcc4f9 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:18:37 +0200 Subject: [PATCH 17/50] add LoiReportAction to propagate the interactions with SubmissionPdfItem; implementation in the fragments --- app/build.gradle | 1 + .../groundplatform/android/di/PdfModule.kt | 100 ++++++++++++++++++ .../datacollection/DataCollectionFragment.kt | 39 ++++++- .../ui/datacollection/DataCollectionScreen.kt | 15 ++- .../DataCollectionScreenPreviews.kt | 23 +++- .../DataSubmissionConfirmationScreen.kt | 66 ++++++++---- .../HomeScreenMapContainerFragment.kt | 39 +++++++ .../HomeScreenMapContainerScreen.kt | 9 +- .../home/mapcontainer/jobs/JobMapComponent.kt | 19 +++- .../mapcontainer/jobs/ShareLocationModal.kt | 55 +++++----- .../components/loireport/LoiReportAction.kt | 22 ++++ 11 files changed, 333 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/di/PdfModule.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt diff --git a/app/build.gradle b/app/build.gradle index d0be27f1bc..1b68b56fd6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -190,6 +190,7 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':core:domain') implementation project(':core:ui') + implementation project(':feature:pdf') implementation libs.androidx.multidex implementation libs.androidx.preference.ktx diff --git a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt new file mode 100644 index 0000000000..f668274719 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/di/PdfModule.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.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.feature.pdf.AndroidPdfImageProvider +import org.groundplatform.feature.pdf.AndroidPdfOutputProvider +import org.groundplatform.feature.pdf.AndroidPdfRenderer +import org.groundplatform.feature.pdf.AndroidPdfReportLauncher +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver + +@Module +@InstallIn(SingletonComponent::class) +object PdfModule { + + @Provides + @Singleton + fun providePdfImageProvider(@ApplicationContext context: Context): PdfImageProvider = + AndroidPdfImageProvider(context = context, logoDrawableRes = R.drawable.ground_logo) + + @Provides + @Singleton + fun providePdfOutputFactory(@ApplicationContext context: Context): PdfOutputProvider = + AndroidPdfOutputProvider(context) + + @Provides @Singleton fun providePdfRenderer(): PdfRenderer = AndroidPdfRenderer() + + @Provides + @Singleton + fun provideTaskValueMapper( + strings: StringResolver, + dateFormatter: DateFormatter, + ): TaskValueMapper = TaskValueMapper(strings = strings, dateFormatter = dateFormatter) + + @Provides + @Singleton + fun provideLoiReportMapper( + taskValueMapper: TaskValueMapper, + strings: StringResolver, + dateFormatter: DateFormatter, + ): LoiReportMapper = + LoiReportMapper( + taskValueMapper = taskValueMapper, + strings = strings, + dateFormatter = dateFormatter, + ) + + @Provides + @Singleton + fun providePdfReportLauncher(@ApplicationContext context: Context): PdfReportLauncher = + AndroidPdfReportLauncher(context = context, fileProviderAuthority = BuildConfig.APPLICATION_ID) + + @Provides + @Singleton + fun providePdfReportService( + imageProvider: PdfImageProvider, + renderer: PdfRenderer, + outputProvider: PdfOutputProvider, + launcher: PdfReportLauncher, + @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 adfc69de01..7169bae63f 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,8 +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 @@ -29,12 +32,16 @@ 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.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.ui.components.loireport.LoiReportAction /** 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 + @Inject lateinit var loiReportMapper: LoiReportMapper val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection) @@ -55,6 +62,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, + onLoiReportAction = { handleLoiReportAction(it) }, ) } @@ -91,6 +99,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/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 bd1c6946a9..7844af3d82 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,6 +24,7 @@ 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 @@ -37,7 +38,11 @@ private const val PAGER_CONTENT_TEXT = "Pager Content Area" @ExcludeFromJacocoGeneratedReport private fun DataCollectionContentLoadingPreview() { AppTheme { - DataCollectionContent(uiState = DataCollectionUiState.Loading, onCloseClicked = {}) { + DataCollectionContent( + uiState = DataCollectionUiState.Loading, + onCloseClicked = {}, + onLoiReportAction = {}, + ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) } @@ -57,6 +62,7 @@ private fun DataCollectionContentErrorPreview() { cause = Error("Some error"), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -82,6 +88,7 @@ private fun DataCollectionContentPreview() { position = TaskPosition(0, 1, 3), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -99,9 +106,21 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), submissionDetails = null) + LoiReport( + loiName = "Point A", + geoJson = JsonObject(mapOf()), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), + ) ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt index 0b18ea8b3a..6931dc8f26 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 @@ -52,12 +52,14 @@ import androidx.compose.ui.unit.dp import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date +import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.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 @@ -68,6 +70,7 @@ fun DataSubmissionConfirmationScreen( modifier: Modifier = Modifier, loiReport: LoiReport? = null, onDismissed: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { val baseModifier = modifier @@ -88,12 +91,16 @@ fun DataSubmissionConfirmationScreen( } } Spacer(modifier = Modifier.width(16.dp)) - ShareableContent(modifier = Modifier.weight(1f), loiReport = loiReport) + ShareableContent( + modifier = Modifier.weight(1f), + loiReport = loiReport, + onLoiReportAction = onLoiReportAction, + ) } } else { Column(modifier = baseModifier, horizontalAlignment = Alignment.CenterHorizontally) { HeaderContent(modifier = Modifier.padding(vertical = 16.dp)) - ShareableContent(loiReport = loiReport) + ShareableContent(loiReport = loiReport, onLoiReportAction = onLoiReportAction) OutlinedButton(modifier = Modifier.padding(vertical = 24.dp), onClick = { onDismissed() }) { Text( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -136,7 +143,11 @@ private fun HeaderContent(modifier: Modifier = Modifier) { } @Composable -private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport?) { +private fun ShareableContent( + modifier: Modifier = Modifier, + loiReport: LoiReport?, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current loiReport?.let { @@ -164,21 +175,15 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } } } @@ -201,19 +206,38 @@ private val testLoiReport = ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) @Composable @Preview(showSystemUi = true) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenPortraitPreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } @Composable @Preview(heightDp = 320, widthDp = 800) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenLandscapePreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt index 432ccdf284..1cffa6015a 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 @@ -44,6 +46,9 @@ import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +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). */ @@ -51,6 +56,8 @@ import timber.log.Timber class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { @Inject lateinit var ephemeralPopups: EphemeralPopups + @Inject lateinit var pdfExportService: PdfExportService + @Inject lateinit var loiReportMapper: LoiReportMapper private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel private lateinit var homeScreenViewModel: HomeScreenViewModel @@ -160,6 +167,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { onJobComponentAction = { handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) }, + onLoiReportAction = { handleLoiReportAction(it) }, ) } } @@ -210,6 +218,37 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } + private fun handleLoiReportAction(action: LoiReportAction) { + 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) + } + } + /** * 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 346a83f0ef..4188494ff0 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -46,11 +46,13 @@ import androidx.compose.ui.window.DialogProperties import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date +import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme @@ -58,7 +60,11 @@ import org.jetbrains.compose.resources.stringResource as multiplatformStringReso @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { +fun ShareLocationModal( + loiReport: LoiReport, + onDismiss: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current Dialog( @@ -95,29 +101,23 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } + } - TextButton( - modifier = Modifier.align(Alignment.End).padding(top = 16.dp), - onClick = onDismiss, - ) { - Text(text = stringResource(R.string.close)) - } + TextButton( + modifier = Modifier.align(Alignment.End).padding(top = 16.dp), + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.close)) } } } @@ -143,12 +143,19 @@ private fun ShareLocationModalPreview() { ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) AppTheme { Surface(modifier = Modifier.fillMaxSize()) { - ShareLocationModal(loiReport = testLoiReport, onDismiss = {}) + ShareLocationModal(loiReport = testLoiReport, onDismiss = {}, onLoiReportAction = {}) } } } diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt new file mode 100644 index 0000000000..208ca81341 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.ui.components.loireport + +sealed interface LoiReportAction { + data object OnShareClicked : LoiReportAction + + data object OnPdfItemClicked : LoiReportAction +} From ad8d18c13f6f21e605158feb3c66fc5643df836b Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:22:58 +0200 Subject: [PATCH 18/50] add getSubmissions to the SubmissionRepository and apply it to setup the data for GetLoiReportUseCase --- .../android/di/GroundApplicationModule.kt | 11 ++++ .../android/di/UseCaseModule.kt | 9 +++- .../repository/SubmissionRepository.kt | 3 ++ .../repository/SubmissionRepositoryTest.kt | 24 +++++++++ .../SubmissionRepositoryInterface.kt | 6 +++ .../domain/usecases/GetLoiReportUseCase.kt | 50 ++++++++++--------- .../usecases/GetLoiReportUseCaseTest.kt | 39 ++++++++++++++- .../testing/FakeSubmissionRepository.kt | 4 ++ 8 files changed, 121 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt index 220304232a..6ecfed8340 100644 --- a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt @@ -27,6 +27,10 @@ import java.util.Locale import javax.inject.Singleton import org.groundplatform.android.R import org.groundplatform.android.util.SurveyDeepLinkParser +import org.groundplatform.ui.util.AndroidDateFormatter +import org.groundplatform.ui.util.ComposeStringResolver +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver @InstallIn(SingletonComponent::class) @Module(includes = [ViewModelModule::class]) @@ -47,4 +51,11 @@ object GroundApplicationModule { deepLinkHost = resources.getString(R.string.deeplink_host), deepLinkPath = resources.getString(R.string.survey_deeplink_path), ) + + @Provides + @Singleton + fun provideDateFormatter(@ApplicationContext context: Context): DateFormatter = + AndroidDateFormatter(context) + + @Provides @Singleton fun provideStringResolver(): StringResolver = ComposeStringResolver } diff --git a/app/src/main/java/org/groundplatform/android/di/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 0568c02072..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 @@ -31,6 +31,7 @@ import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LoiProperties import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface +import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.util.toFixedDecimals @@ -44,37 +45,40 @@ class GetLoiReportUseCase( private val locationOfInterestRepository: LocationOfInterestRepositoryInterface, private val userRepositoryInterface: UserRepositoryInterface, private val surveyRepositoryInterface: SurveyRepositoryInterface, + private val submissionRepositoryInterface: SubmissionRepositoryInterface, ) { /** * Returns a [LoiReport] for the given LOI, or `null` if it does not exist. * - * @param loiName the identifier of the location of interest. + * @param loiName the name of the location of interest + * @param loiId the identifier of the location of interest. * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { - val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) - val user = userRepositoryInterface.getAuthenticatedUser() - val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() - val submissions = null // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 - return loi?.let { - LoiReport( - loiName = loiName, - geoJson = - it.geometry.toGeoJson( - it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } - ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = surveyName, - userName = user.displayName, - userEmail = user.email, - dateMillis = loi.lastModified.clientTimestamp, - submissions = submissions, - ), - ) - } + val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null + val submissions = + submissionRepositoryInterface.getSubmissions(loi).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( + loiName = loiName, + geoJson = + loi.geometry.toGeoJson( + loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } + ), + submissionDetails = submissionDetails, + ) } /** diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt index 95a316eed7..507f7d86db 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.submissionDetails!!.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/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 7cfe3e14cfd4f215a851a82ba0b52ec54123da80 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 3 Jun 2026 10:34:12 +0200 Subject: [PATCH 19/50] update SubmissionData to not treat skipped and null submissions equally --- .../groundplatform/domain/model/submission/SubmissionData.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt index 222a581415..a76c82812d 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt @@ -36,8 +36,9 @@ data class SubmissionData(private val data: Map = mapOf()) { fun copyWithDeltas(deltas: List): SubmissionData { val newData = data.toMutableMap() deltas.forEach { - if (it.newTaskData.isNotNullOrEmpty()) { - newData[it.taskId] = it.newTaskData + val newTaskData = it.newTaskData + if (newTaskData is SkippedTaskData || newTaskData.isNotNullOrEmpty()) { + newData[it.taskId] = newTaskData } else { newData.remove(it.taskId) } From 1bb98f2ff0b8457f6991a3b88ff24c95db0ad4eb Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 3 Jun 2026 18:33:39 +0200 Subject: [PATCH 20/50] extract common logic for the footer reserve and table building --- feature/pdf/build.gradle.kts | 2 +- .../feature/pdf/render/DocumentPdfCanvas.kt | 22 +++- .../feature/pdf/render/PdfCanvas.kt | 17 ++- .../feature/pdf/render/PdfTextPaints.kt | 15 +++ .../feature/pdf/render/PdfWriter.kt | 119 +++++++---------- .../feature/pdf/render/PdfCursor.kt | 7 +- .../feature/pdf/render/PdfGeometry.kt | 2 + .../pdf/render/components/PageFooterLayout.kt | 8 ++ .../pdf/render/components/TableLayout.kt | 112 ++++++++++++++++ .../pdf/render/components/TableRowLayout.kt | 90 ------------- .../feature/pdf/render/PdfCursorTest.kt | 27 ++-- .../pdf/render/PdfPageControllerTest.kt | 2 +- .../render/components/PageFooterLayoutTest.kt | 7 + .../render/components/PageHeaderLayoutTest.kt | 2 +- ...bleRowLayoutTest.kt => TableLayoutTest.kt} | 121 ++++++++++++++---- 15 files changed, 348 insertions(+), 205 deletions(-) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt delete mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt rename feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/{TableRowLayoutTest.kt => TableLayoutTest.kt} (51%) diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index d75697daac..4bc48dc710 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -45,7 +45,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) - implementation(libs.compose.ui) } } @@ -61,6 +60,7 @@ kotlin { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.exifinterface) + implementation(libs.compose.ui) } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt index 5bbebca4f0..069da903a8 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.Canvas @@ -9,7 +24,7 @@ import androidx.core.graphics.withTranslation import org.groundplatform.feature.pdf.render.image.PdfImage /** - * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * [PdfCanvas] that draws onto a real [PdfDocument], one page at a time. Image bitmaps are expected * to arrive at their on-page pixel size; the canvas does no further scaling. */ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { @@ -30,7 +45,8 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } override fun startPage(pageNumber: Int) { - val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + val info = + PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() currentPage = pdf.startPage(info) } @@ -52,4 +68,4 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") -} \ No newline at end of file +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt index 3fa449022e..8f532c7a94 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.RectF @@ -28,4 +43,4 @@ internal object MeasurementPdfCanvas : PdfCanvas { override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit -} \ No newline at end of file +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt index f60a0f41a9..25b8c3913e 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.Color diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index dabba56a6f..ebe78bfb38 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.RectF @@ -16,9 +31,7 @@ import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT @@ -27,7 +40,7 @@ import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH import org.groundplatform.feature.pdf.render.components.PageFooterLayout import org.groundplatform.feature.pdf.render.components.PageHeaderLayout import org.groundplatform.feature.pdf.render.components.QrBlockLayout -import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.components.TableLayout import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet @@ -45,24 +58,11 @@ internal class PdfWriter( ) : PdfPageController.PageLifecycle { private val paints = PdfTextPaints() - private val cursor = PdfCursor() + private val footerLayout: StaticLayout = buildFooterLayout(footer) + private val cursor = + PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) private val pageController = PdfPageController(cursor, this) - private var currentTableTopY: Float? = null - - private val footerLayout: StaticLayout - - init { - val footerLabel = footer.dataCollectorLabel - val footerText = - SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { - setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - footerLayout = - staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) - cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP - } - val pageCount: Int get() = pageController.pageCount @@ -72,7 +72,6 @@ internal class PdfWriter( } override fun onPageEnding(pageNumber: Int) { - flushTableDivider() drawPageFooter() pdfCanvas.finishPage() } @@ -92,8 +91,6 @@ internal class PdfWriter( fun drawTable(table: SubmissionPdfDocument.Table) { val rows = table.rows.takeIf { it.isNotEmpty() } ?: return pageController.ensurePage() - val x = MARGIN.toFloat() - cursor.advance(LINE_SPACING * 2) val label = SpannableString("${table.submissionLabel}: ${table.loiName}").apply { setSpan( @@ -103,8 +100,10 @@ internal class PdfWriter( Spanned.SPAN_INCLUSIVE_EXCLUSIVE, ) } - cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) - cursor.advance(LINE_SPACING) + val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) + val tableLabel = TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) + cursor.moveTo(tableLabel.nextCursorY) rows.forEach { row -> when (val answer = row.answer) { is Answer.Text -> @@ -121,7 +120,6 @@ internal class PdfWriter( ) } } - flushTableDivider() } fun finalizePage() { @@ -171,15 +169,15 @@ internal class PdfWriter( val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) drawStaticLayoutAt(footerLayout, layout.footerTextOffset) totalPages?.let { total -> - drawText( - text = "${pageController.pageCount}/$total", - x = layout.pageNumberOffset.x, - y = layout.pageNumberOffset.y, - maxWidth = layout.pageNumberMaxWidth, - paint = paints.meta, - alignment = Layout.Alignment.ALIGN_OPPOSITE, - maxLines = 1, - ) + val pageNumber = + staticLayout( + "${pageController.pageCount}/$total", + paints.meta, + layout.pageNumberMaxWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + drawStaticLayoutAt(pageNumber, layout.pageNumberOffset) } } @@ -194,22 +192,16 @@ internal class PdfWriter( val questionHeight = questionLayout.height.toFloat() val answerHeight = answerLayout?.height?.toFloat() ?: 0f - pageController.newPageIfShort( - TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) - ) + pageController.newPageIfShort(TableLayout.getRowHeight(questionHeight, answerHeight, photoSize)) val rowLayout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = cursor.y, leftTextHeight = questionHeight, rightTextHeight = answerHeight, rightImageSize = photoSize, ) - if (currentTableTopY == null) { - currentTableTopY = cursor.y - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - + rowLayout.borderLines.forEach { drawLine(it) } drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) if (answerLayout != null && rowLayout.rightTextOffset != null) { drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) @@ -218,35 +210,6 @@ internal class PdfWriter( drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) } cursor.advance(rowLayout.totalHeight) - - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - - private fun flushTableDivider() { - val top = currentTableTopY ?: return - val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() - pdfCanvas.drawLine(midX, top, midX, cursor.y) - currentTableTopY = null - } - - /** - * Lays out [text] and draws it at ([x], [y]). - * - * @return the Y just below the drawn text. - */ - private fun drawText( - text: CharSequence, - 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) - pdfCanvas.drawStaticLayout(layout, x, y) - return y + layout.height } private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = @@ -255,6 +218,18 @@ internal class PdfWriter( private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + private fun drawLine(line: PdfLine) = + pdfCanvas.drawLine(line.startX, line.startY, line.endX, line.endY) + + private fun buildFooterLayout(footer: Footer): StaticLayout { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + return staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + } + /** * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a * single long value can't grow the layout unboundedly. diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index 2c67ea1f02..ccd2b8139c 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,12 +17,13 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( + /** + * Space kept clear above the bottom margin for the footer. + */ + private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.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 diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt index 40b0fc8ce0..3b55da2103 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -30,6 +30,8 @@ internal data class PdfItemSize(val width: Float, val height: Float) internal data class PdfOffset(val x: Float, val y: Float) +internal data class PdfLine(val startX: Float, val startY: Float, val endX: Float, val endY: Float) + /** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { val right: Float diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt index adf5b3976d..9abf5498de 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -16,6 +16,7 @@ package org.groundplatform.feature.pdf.render.components import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH @@ -37,6 +38,13 @@ internal data class PageFooterLayout( val pageNumberMaxWidth: Int, ) { companion object { + /** + * Vertical space the footer occupies, including the [FOOTER_TOP_GAP] separating it from page + * content. Feed this to [org.groundplatform.feature.pdf.render.PdfCursor] so pagination keeps + * the footer clear of content. + */ + fun reserve(footerHeight: Float): Float = footerHeight + FOOTER_TOP_GAP + fun compute(footerHeight: Float): PageFooterLayout { val top = PAGE_HEIGHT - MARGIN - footerHeight val left = MARGIN.toFloat() diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt new file mode 100644 index 0000000000..be1d885ae6 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect +internal object TableLayout { + /** + * Layout of a single two-column table row. Left cell holds a single text block; right cell may + * contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no + * image. + * @param borderLines The row's own frame: top border, bottom border, and column divider. + */ + data class Row( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, + val borderLines: List, + ) + + data class Label(val labelOffset: PdfOffset, val nextCursorY: Float) + + fun getLabel(top: Float, labelHeight: Float): Label { + val labelTop = top + LINE_SPACING * 2 + return Label( + labelOffset = PdfOffset(MARGIN.toFloat(), labelTop), + nextCursorY = labelTop + labelHeight + LINE_SPACING, + ) + } + + /** Row height for the page-fit check, before the final row position is known. */ + fun getRowHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun getRow( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Row { + val totalHeight = getRowHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val right = left + USABLE_WIDTH + val midX = left + TABLE_TASK_COLUMN_WIDTH + val rowBottom = rowTop + totalHeight + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return Row( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = right, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + borderLines = + listOf( + PdfLine(startX = left, startY = rowTop, endX = right, endY = rowTop), + PdfLine(startX = left, startY = rowBottom, endX = right, endY = rowBottom), + PdfLine(startX = midX, startY = rowTop, endX = midX, endY = rowBottom), + ), + ) + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt deleted file mode 100644 index cf7e2de67c..0000000000 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.feature.pdf.render.components - -import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING -import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN -import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH -import org.groundplatform.feature.pdf.render.PdfItemSize -import org.groundplatform.feature.pdf.render.PdfOffset -import org.groundplatform.feature.pdf.render.PdfRect - -/** - * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell - * may contain either text or an image. - * - * @param totalHeight Total height of the row including vertical padding. - * @param leftRowX X coordinate of the row's left edge. - * @param rightRowX X coordinate of the row's right edge. - * @param columnDividerX X coordinate of the vertical divider between the two columns. - * @param leftTextOffset Top-left position where the left cell text should be drawn. - * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if - * the right cell has no text. - * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. - */ -internal data class TableRowLayout( - val totalHeight: Float, - val leftRowX: Float, - val rightRowX: Float, - val columnDividerX: Float, - val leftTextOffset: PdfOffset, - val rightTextOffset: PdfOffset?, - val rightImageFrame: PdfRect?, -) { - companion object { - fun totalHeight( - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): Float { - val imageHeight = rightImageSize?.height ?: 0f - val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f - return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING - } - - fun compute( - rowTop: Float, - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): TableRowLayout { - val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) - - val left = MARGIN.toFloat() - val midX = left + TABLE_TASK_COLUMN_WIDTH - val contentTop = rowTop + CELL_PADDING - val rightCellLeft = midX + CELL_PADDING - - val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null - val rightImageFrame = rightImageSize?.let { - val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) - PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) - } - - return TableRowLayout( - totalHeight = totalHeight, - leftRowX = left, - rightRowX = left + USABLE_WIDTH, - columnDividerX = midX, - leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), - rightTextOffset = rightTextOffset, - rightImageFrame = rightImageFrame, - ) - } - } -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt index 06bf9f85d9..263343b1bd 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -24,7 +24,7 @@ class PdfCursorTest { @Test fun `fresh cursor starts at the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) assertTrue(cursor.isAtPageTop) @@ -32,7 +32,7 @@ class PdfCursorTest { @Test fun `advance moves the cursor down by the given delta`() { - val cursor = PdfCursor() + val cursor = newCursor() val start = cursor.y cursor.advance(75f) @@ -43,7 +43,7 @@ class PdfCursorTest { @Test fun `moveTo sets the cursor to an absolute Y`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.moveTo(400f) @@ -52,7 +52,7 @@ class PdfCursorTest { @Test fun `reset returns the cursor to the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.advance(300f) cursor.reset() @@ -63,7 +63,7 @@ class PdfCursorTest { @Test fun `isAtPageTop reflects whether Y matches the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertTrue(cursor.isAtPageTop) cursor.advance(1f) @@ -75,7 +75,7 @@ class PdfCursorTest { @Test fun `fits returns true when there is room above the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertTrue(cursor.fits(available.toFloat())) @@ -84,7 +84,7 @@ class PdfCursorTest { @Test fun `fits returns false when the requested height overflows the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertFalse(cursor.fits(available + 1f)) @@ -92,9 +92,8 @@ class PdfCursorTest { @Test fun `fits subtracts the footer reserve from the available space`() { - val cursor = PdfCursor() + val cursor = newCursor(footerReserve = 50f) val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() - cursor.footerReserve = 50f assertTrue(cursor.fits(available - 50f)) assertFalse(cursor.fits(available - 49f)) @@ -102,7 +101,7 @@ class PdfCursorTest { @Test fun `fits depends on the current Y position`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() cursor.advance(100f) @@ -113,11 +112,17 @@ class PdfCursorTest { @Test fun `custom page height and margin are respected`() { - val cursor = PdfCursor(pageHeight = 200, margin = 10) + val cursor = newCursor(pageHeight = 200, margin = 10) assertEquals(10f, cursor.y) // Usable height = 200 - 2*10 = 180. assertTrue(cursor.fits(180f)) assertFalse(cursor.fits(181f)) } + + private fun newCursor( + footerReserve: Float = 0f, + pageHeight: Int = PdfConfig.PAGE_HEIGHT, + margin: Int = PdfConfig.MARGIN, + ) = PdfCursor(footerReserve = footerReserve, pageHeight = pageHeight, margin = margin) } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt index dccda1cf90..4ad96927a6 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -33,7 +33,7 @@ class PdfPageControllerTest { events += PageEvent.Ending(pageNumber) } } - private val cursor = PdfCursor() + private val cursor = PdfCursor(footerReserve = 0f) private val controller = PdfPageController(cursor, lifecycle) @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt index e15a3f4c36..8b56b1b98b 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -88,4 +88,11 @@ class PageFooterLayoutTest { assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) } + + @Test + fun `reserve adds the top gap to the footer height`() { + val footerHeight = 12f + + assertEquals(footerHeight + PdfConfig.FOOTER_TOP_GAP, PageFooterLayout.reserve(footerHeight)) + } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt index 792fa0e62d..b15573a95a 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -40,7 +40,7 @@ class PageHeaderLayoutTest { assertTrue(center < right) assertEquals(headerColumnGap.toFloat(), center - (left + width)) assertEquals(headerColumnGap.toFloat(), right - (center + width)) - assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + assertTrue(3 * width + 2 * headerColumnGap <= usableWidth) } @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt similarity index 51% rename from feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt index f8b54c350a..287a7341da 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt @@ -22,9 +22,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.groundplatform.feature.pdf.render.PdfConfig import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset -class TableRowLayoutTest { +class TableLayoutTest { private val cellPadding = PdfConfig.CELL_PADDING.toFloat() private val lineSpacing = PdfConfig.LINE_SPACING @@ -33,21 +34,47 @@ class TableRowLayoutTest { private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH @Test - fun `totalHeight with only left text returns left height plus padding`() { + fun `label sits below a top gap at the left margin`() { + val layout = TableLayout.getLabel(top = 100f, labelHeight = 14f) + + assertEquals(PdfOffset(margin, 100f + 2 * lineSpacing), layout.labelOffset) + } + + @Test + fun `label leaves a bottom gap before the first row`() { + val top = 100f + val labelHeight = 14f + + val layout = TableLayout.getLabel(top = top, labelHeight = labelHeight) + + assertEquals(top + 2 * lineSpacing + labelHeight + lineSpacing, layout.nextCursorY) + } + + @Test + fun `taller label pushes the first row further down`() { + val short = TableLayout.getLabel(top = 0f, labelHeight = 10f) + val tall = TableLayout.getLabel(top = 0f, labelHeight = 30f) + + assertTrue(short.nextCursorY < tall.nextCursorY) + assertEquals(20f, tall.nextCursorY - short.nextCursorY) + } + + @Test + fun `rowHeight with only left text returns left height plus padding`() { val height = - TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) assertEquals(30f + 2 * cellPadding, height) } @Test - fun `totalHeight picks the taller content height`() { + fun `rowHeight picks the taller content height`() { val tallerLeft = - TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) val tallerRight = - TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) val tallerImageRight = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 20f, rightTextHeight = 0f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -59,9 +86,9 @@ class TableRowLayoutTest { } @Test - fun `totalHeight with both right text and image stacks them with line spacing`() { + fun `rowHeight with both right text and image stacks them with line spacing`() { val height = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 10f, rightTextHeight = 20f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -71,9 +98,9 @@ class TableRowLayoutTest { } @Test - fun `compute always places left text at the row's top-left content area`() { + fun `row always places left text at the row's top-left content area`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 100f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -84,9 +111,9 @@ class TableRowLayoutTest { } @Test - fun `compute returns null right offsets when right cell has no content`() { + fun `row returns null right offsets when right cell has no content`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -98,9 +125,9 @@ class TableRowLayoutTest { } @Test - fun `compute places right text at the right cell's top`() { + fun `row places right text at the right cell's top`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 20f, @@ -113,10 +140,10 @@ class TableRowLayoutTest { } @Test - fun `compute places image at the right cell's top`() { + fun `row places image at the right cell's top`() { val imageSize = PdfItemSize(width = 80f, height = 60f) val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -135,9 +162,9 @@ class TableRowLayoutTest { } @Test - fun `compute sets row bounds and divider from page geometry`() { + fun `row sets bounds and divider from page geometry`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -152,12 +179,62 @@ class TableRowLayoutTest { } @Test - fun `compute totalHeight matches the static helper`() { + fun `row frames itself with top, bottom, and column-divider border lines`() { + val rowTop = 100f + val layout = + TableLayout.getRow( + rowTop = rowTop, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rowBottom = rowTop + layout.totalHeight + val right = margin + usableWidth + val midX = margin + taskColumnWidth + assertEquals( + listOf( + PdfLine(margin, rowTop, right, rowTop), + PdfLine(margin, rowBottom, right, rowBottom), + PdfLine(midX, rowTop, midX, rowBottom), + ), + layout.borderLines, + ) + } + + @Test + fun `consecutive rows produce abutting borders so the divider reads as one continuous line`() { + val first = + TableLayout.getRow( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + val second = + TableLayout.getRow( + rowTop = 50f + first.totalHeight, + leftTextHeight = 30f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + // The first row's bottom border sits exactly where the second row's top border begins. + assertEquals(first.borderLines[1].startY, second.borderLines[0].startY) + // The per-row divider segments share an X and meet end-to-start, forming one unbroken line. + val firstDivider = first.borderLines[2] + val secondDivider = second.borderLines[2] + assertEquals(firstDivider.endX, secondDivider.startX) + assertEquals(firstDivider.endY, secondDivider.startY) + } + + @Test + fun `row totalHeight matches the rowHeight helper`() { val left = 30f val right = 20f val image = PdfItemSize(width = 80f, height = 60f) - val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + val layout = TableLayout.getRow(rowTop = 0f, left, right, image) - assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + assertEquals(TableLayout.getRowHeight(left, right, image), layout.totalHeight) } } From 255f1e2cd182b9dd2736508b514cd094d0b995db Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 8 Jun 2026 17:26:16 +0200 Subject: [PATCH 21/50] extract fragment logic to LoiExporter and add tests --- .../groundplatform/android/di/PdfModule.kt | 8 ++ .../datacollection/DataCollectionFragment.kt | 24 +---- .../HomeScreenMapContainerFragment.kt | 23 +---- .../feature/pdf/LoiReportExporter.kt | 38 ++++++++ .../feature/pdf/render/PdfCursor.kt | 4 +- .../pdf/render/components/TableLayout.kt | 1 + .../feature/pdf/LoiReportExporterTest.kt | 89 +++++++++++++++++++ .../pdf/helpers/FakePdfExportService.kt | 82 +++++++++++++++++ 8 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt diff --git a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt index f668274719..f2f18bfde8 100644 --- a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt @@ -30,6 +30,7 @@ import org.groundplatform.feature.pdf.AndroidPdfImageProvider import org.groundplatform.feature.pdf.AndroidPdfOutputProvider import org.groundplatform.feature.pdf.AndroidPdfRenderer import org.groundplatform.feature.pdf.AndroidPdfReportLauncher +import org.groundplatform.feature.pdf.LoiReportExporter import org.groundplatform.feature.pdf.PdfExportService import org.groundplatform.feature.pdf.PdfImageProvider import org.groundplatform.feature.pdf.PdfOutputProvider @@ -97,4 +98,11 @@ object PdfModule { launcher = launcher, coroutineDispatcher = coroutineDispatcher, ) + + @Provides + @Singleton + fun provideLoiReportExporter( + mapper: LoiReportMapper, + exportService: PdfExportService, + ): LoiReportExporter = LoiReportExporter(mapper = mapper, exportService = exportService) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt index 7169bae63f..4794b4517a 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 @@ -32,16 +32,14 @@ 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.feature.pdf.PdfExportService -import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.LoiReportExporter import org.groundplatform.ui.components.loireport.LoiReportAction /** 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 - @Inject lateinit var loiReportMapper: LoiReportMapper + @Inject lateinit var loiReportExporter: LoiReportExporter val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection) @@ -106,25 +104,9 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { 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) + loiReportExporter.export(loiReport, action).onFailure { popups.ErrorPopup().unknownError() } } } 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 1cffa6015a..97f2f3a82d 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 @@ -46,8 +46,7 @@ import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY -import org.groundplatform.feature.pdf.PdfExportService -import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.LoiReportExporter import org.groundplatform.ui.components.loireport.LoiReportAction import timber.log.Timber @@ -56,8 +55,7 @@ import timber.log.Timber class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { @Inject lateinit var ephemeralPopups: EphemeralPopups - @Inject lateinit var pdfExportService: PdfExportService - @Inject lateinit var loiReportMapper: LoiReportMapper + @Inject lateinit var loiReportExporter: LoiReportExporter private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel private lateinit var homeScreenViewModel: HomeScreenViewModel @@ -227,25 +225,10 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { 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) { + loiReportExporter.export(loiReport, action).onFailure { 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt new file mode 100644 index 0000000000..005ed0327c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporter( + private val mapper: LoiReportMapper, + private val exportService: PdfExportService, +) { + suspend fun export(loiReport: LoiReport, action: LoiReportAction): Result = runCatching { + val pdfAction = + when (action) { + LoiReportAction.OnShareClicked -> PdfExportService.Action.Share + LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open + } + + val submission = + loiReport.submissionDetails?.submissions?.firstOrNull() ?: error("No submission to export") + val request = mapper.map(loiReport, submission) ?: error("Failed to map LoiReport") + exportService.export(request, pdfAction) + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index ccd2b8139c..4559f1e115 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,9 +17,7 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( - /** - * Space kept clear above the bottom margin for the footer. - */ + /** Space kept clear above the bottom margin for the footer. */ private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.MARGIN, diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt index be1d885ae6..abae4d1589 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -24,6 +24,7 @@ import org.groundplatform.feature.pdf.render.PdfItemSize import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset import org.groundplatform.feature.pdf.render.PdfRect + internal object TableLayout { /** * Layout of a single two-column table row. Left cell holds a single text block; right cell may diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt new file mode 100644 index 0000000000..cce188c3c0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakePdfExportService +import org.groundplatform.feature.pdf.helpers.FakeStringResolver +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporterTest { + + private val mapper = + LoiReportMapper( + taskValueMapper = + TaskValueMapper(strings = FakeStringResolver, dateFormatter = FakeDateFormatter), + strings = FakeStringResolver, + dateFormatter = FakeDateFormatter, + ) + + @Test + fun `opens the report when the action is OnPdfItemClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.openedPath) + assertNull(service.sharedPath) + } + + @Test + fun `shares the report when the action is OnShareClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnShareClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.sharedPath) + assertNull(service.openedPath) + } + + @Test + fun `returns failure without exporting when report has no submission`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + val loiReport = + FakeDataGenerator.newLoiReport( + submissionDetails = FakeDataGenerator.newSubmissionDetails(submissions = emptyList()) + ) + + val result = exporter.export(loiReport, LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + assertNull(service.openedPath) + } + + @Test + fun `returns failure when export throws`() = runTest { + val service = FakePdfExportService().apply { renderError = RuntimeException("boom") } + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt new file mode 100644 index 0000000000..0d957593c6 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.helpers + +import kotlinx.coroutines.Dispatchers +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +class FakePdfExportService(val outputPath: String = "/tmp/report.pdf") { + var renderError: Throwable? = null + + var openedPath: String? = null + private set + + var sharedPath: String? = null + private set + + var imagesReleased: Boolean = false + private set + + val deletedPaths: MutableList = mutableListOf() + + val service: PdfExportService = + PdfExportService( + imageProvider = + object : PdfImageProvider { + override suspend fun load(qrContent: String?, photoFilenames: Set) = + PdfImageSet(images = emptyMap(), onRelease = { imagesReleased = true }) + }, + renderer = + object : PdfRenderer { + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + renderError?.let { throw it } + } + }, + outputProvider = + object : PdfOutputProvider { + override fun newFilePath(name: String) = outputPath + + override fun exists(name: String) = false + + override fun listFiles() = emptyList() + + override fun deleteReport(path: String) { + deletedPaths.add(path) + } + }, + launcher = + object : PdfReportLauncher { + override fun share(path: String) { + sharedPath = path + } + + override fun open(path: String) { + openedPath = path + } + }, + coroutineDispatcher = Dispatchers.Unconfined, + ) +} From 54e67b38da500b6d9332829ef272f841cd9130ff Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 09:27:13 +0200 Subject: [PATCH 22/50] fix merge conflicts --- .../android/ui/datacollection/DataCollectionScreenPreviews.kt | 1 + .../ui/datacollection/DataSubmissionConfirmationScreen.kt | 1 + .../android/ui/home/mapcontainer/jobs/ShareLocationModal.kt | 1 + 3 files changed, 3 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 f24629bcbb..10b2b1c88a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt @@ -29,6 +29,7 @@ import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock private const val PAGER_CONTENT_TEXT = "Pager Content Area" 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 d4710376f0..f41d88e03c 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 @@ -62,6 +62,7 @@ import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @Composable 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 adcb156b87..4696679ed8 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 @@ -55,6 +55,7 @@ import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @OptIn(ExperimentalMaterial3Api::class) From ee00650880d22ed49a4e3576224001472d3d3be5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:41:58 +0200 Subject: [PATCH 23/50] add common layout components to render --- .../feature/pdf/render/PdfConfig.kt | 51 ++++++ .../feature/pdf/render/PdfGeometry.kt | 40 +++++ .../pdf/render/components/PageFooterLayout.kt | 52 ++++++ .../pdf/render/components/PageHeaderLayout.kt | 64 +++++++ .../pdf/render/components/QrBlockLayout.kt | 52 ++++++ .../pdf/render/components/TableRowLayout.kt | 90 ++++++++++ .../render/components/PageFooterLayoutTest.kt | 91 ++++++++++ .../render/components/PageHeaderLayoutTest.kt | 107 ++++++++++++ .../render/components/QrBlockLayoutTest.kt | 74 ++++++++ .../render/components/TableRowLayoutTest.kt | 163 ++++++++++++++++++ 10 files changed, 784 insertions(+) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt new file mode 100644 index 0000000000..a502c8dccd --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** + * 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). + */ +internal object PdfConfig { + const val PAGE_WIDTH = 595 // A4 page width + const val PAGE_HEIGHT = 842 // A4 page height + const val MARGIN = 40 + const val TITLE_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_TASK_LABEL_RATIO = 0.35f + const val CELL_PADDING = 6 + const val BORDER_WIDTH = 0.5f + const val PHOTO_MAX_HEIGHT_RATIO = 0.35f + const val HEADER_BOTTOM_GAP = 28f + const val FOOTER_TOP_GAP = 28f + const val MAX_HEADER_VALUE_LINES = 1 + const val MAX_FOOTER_LINES = 1 + const val IMAGE_RENDER_DPI = 300f + const val USABLE_WIDTH = PAGE_WIDTH - 2 * MARGIN + const val TABLE_TASK_COLUMN_WIDTH = (USABLE_WIDTH * TABLE_TASK_LABEL_RATIO).toInt() + const val TABLE_TASK_TEXT_WIDTH = TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val TABLE_ANSWER_TEXT_WIDTH = USABLE_WIDTH - TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val PHOTO_MAX_HEIGHT = ((PAGE_HEIGHT - 2 * MARGIN) * PHOTO_MAX_HEIGHT_RATIO).toInt() + const val PAGE_NUMBER_BAND_WIDTH = 60 + const val FOOTER_PAGE_NUMBER_GAP = 8 + const val FOOTER_TEXT_MAX_WIDTH = USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH - FOOTER_PAGE_NUMBER_GAP +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt new file mode 100644 index 0000000000..40b0fc8ce0 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.math.roundToInt + +internal fun fitInside(width: Int, height: Int, maxWidth: Int, maxHeight: Int): PdfItemSize { + val scale = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height, 1f) + return PdfItemSize(width * scale, height * scale) +} + +internal fun pointsToRenderPixels(points: Float): Int = + // 1 point = 1/72 inch (standard PDF user-space unit) + (points / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt() + +internal data class PdfItemSize(val width: Float, val height: Float) + +internal data class PdfOffset(val x: Float, val y: Float) + +/** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ +internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { + val right: Float + get() = x + width + + val bottom: Float + get() = y + height +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt new file mode 100644 index 0000000000..adf5b3976d --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page footer with separate left and right slots. + * + * @param footerTextOffset The top-left position where the footer text begins. + * @param footerMaxWidth The maximum width available for the footer text. + * @param pageNumberOffset The top-left position where the page number begins. + * @param pageNumberMaxWidth The maximum width available for the page number + */ +internal data class PageFooterLayout( + val footerTextOffset: PdfOffset, + val footerMaxWidth: Int, + val pageNumberOffset: PdfOffset, + val pageNumberMaxWidth: Int, +) { + companion object { + fun compute(footerHeight: Float): PageFooterLayout { + val top = PAGE_HEIGHT - MARGIN - footerHeight + val left = MARGIN.toFloat() + val pageNumberLeft = left + USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH + return PageFooterLayout( + footerTextOffset = PdfOffset(left, top), + footerMaxWidth = FOOTER_TEXT_MAX_WIDTH, + pageNumberOffset = PdfOffset(pageNumberLeft, top), + pageNumberMaxWidth = PAGE_NUMBER_BAND_WIDTH, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt new file mode 100644 index 0000000000..c02bbb6025 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_BOTTOM_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_COLUMN_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page header with three slots. Assumes uniform typography across + * columns. + * + * @param leftColumn Label and value positions for the left column. + * @param centerColumn Label and value positions for the center column. + * @param rightTextOffset The position where the right-aligned value begins . + * @param nextCursorY The Y position where the cursor should be positioned after the header. + */ +internal data class PageHeaderLayout( + val leftColumn: Column, + val centerColumn: Column, + val rightTextOffset: PdfOffset, + val nextCursorY: Float, +) { + companion object { + const val COLUMN_WIDTH: Int = (USABLE_WIDTH - 2 * HEADER_COLUMN_GAP) / 3 + const val LEFT_X: Float = MARGIN.toFloat() + const val CENTER_X: Float = LEFT_X + COLUMN_WIDTH + HEADER_COLUMN_GAP + const val RIGHT_X: Float = LEFT_X + 2 * (COLUMN_WIDTH + HEADER_COLUMN_GAP) + + fun compute(top: Float, labelHeight: Float, valueHeight: Float): PageHeaderLayout { + val columnBottom = top + labelHeight + LINE_SPACING + valueHeight + return PageHeaderLayout( + leftColumn = column(LEFT_X, top, labelHeight), + centerColumn = column(CENTER_X, top, labelHeight), + rightTextOffset = PdfOffset(RIGHT_X, top), + nextCursorY = columnBottom + HEADER_BOTTOM_GAP, + ) + } + + private fun column(x: Float, top: Float, labelHeight: Float) = + Column( + labelOffset = PdfOffset(x, top), + valueOffset = PdfOffset(x, top + labelHeight + LINE_SPACING), + ) + } + + data class Column(val labelOffset: PdfOffset, val valueOffset: PdfOffset) +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt new file mode 100644 index 0000000000..aafb3b964a --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.QR_SIZE +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect + +/** + * Pre-computed layout for the right-aligned QR code block with its caption. Compute should only be + * called when a QR image is available; the caption is meaningless without it. + * + * @param qrFrame Position and size of the QR image. + * @param captionOffset Top-left position of the caption text (centered under the QR). + * @param captionMaxWidth Maximum width for the caption. + * @param nextCursorY Cursor Y position after this block. + */ +internal data class QrBlockLayout( + val qrFrame: PdfRect, + val captionOffset: PdfOffset, + val captionMaxWidth: Int, + val nextCursorY: Float, +) { + companion object { + fun compute(top: Float, captionHeight: Float): QrBlockLayout { + val x = (PAGE_WIDTH - MARGIN - QR_SIZE).toFloat() + val captionTop = top + QR_SIZE + LINE_SPACING + return QrBlockLayout( + qrFrame = PdfRect(x, top, QR_SIZE.toFloat(), QR_SIZE.toFloat()), + captionOffset = PdfOffset(x, captionTop), + captionMaxWidth = QR_SIZE, + nextCursorY = captionTop + captionHeight + LINE_SPACING * 2, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt new file mode 100644 index 0000000000..cf7e2de67c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect + +/** + * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell + * may contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. + */ +internal data class TableRowLayout( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, +) { + companion object { + fun totalHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun compute( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): TableRowLayout { + val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val midX = left + TABLE_TASK_COLUMN_WIDTH + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return TableRowLayout( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = left + USABLE_WIDTH, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + ) + } + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt new file mode 100644 index 0000000000..e15a3f4c36 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig + +class PageFooterLayoutTest { + + private val margin = PdfConfig.MARGIN.toFloat() + private val pageHeight = PdfConfig.PAGE_HEIGHT + private val usableWidth = PdfConfig.USABLE_WIDTH + private val pageNumberBand = PdfConfig.PAGE_NUMBER_BAND_WIDTH + private val footerTextMaxWidth = PdfConfig.FOOTER_TEXT_MAX_WIDTH + + @Test + fun `footer text anchors against the bottom margin`() { + val footerHeight = 12f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(margin, layout.footerTextOffset.x) + assertEquals(pageHeight - margin - footerHeight, layout.footerTextOffset.y) + } + + @Test + fun `page number sits in the right-side slot on the same baseline as the footer text`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(margin + usableWidth - pageNumberBand, layout.pageNumberOffset.x) + assertEquals(layout.footerTextOffset.y, layout.pageNumberOffset.y) + } + + @Test + fun `slot widths match their respective configuration constants`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(footerTextMaxWidth, layout.footerMaxWidth) + assertEquals(pageNumberBand, layout.pageNumberMaxWidth) + } + + @Test + fun `footer and page number slots do not overlap`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + val footerRight = layout.footerTextOffset.x + layout.footerMaxWidth + assertTrue(footerRight <= layout.pageNumberOffset.x) + } + + @Test + fun `page number band ends exactly at the right page margin`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals( + margin + usableWidth, + layout.pageNumberOffset.x + layout.pageNumberMaxWidth, + "Page number band's right edge must align with the right page margin", + ) + } + + @Test + fun `taller footer pushes the baseline higher up the page`() { + val short = PageFooterLayout.compute(footerHeight = 12f) + val tall = PageFooterLayout.compute(footerHeight = 30f) + + assertTrue(tall.footerTextOffset.y < short.footerTextOffset.y) + assertEquals(short.footerTextOffset.y - 18f, tall.footerTextOffset.y) + } + + @Test + fun `footer height plus baseline plus bottom margin equals page height`() { + val footerHeight = 18f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt new file mode 100644 index 0000000000..792fa0e62d --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.text.compareTo +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfOffset + +class PageHeaderLayoutTest { + + private val lineSpacing = PdfConfig.LINE_SPACING + private val headerBottomGap = PdfConfig.HEADER_BOTTOM_GAP + private val headerColumnGap = PdfConfig.HEADER_COLUMN_GAP + private val usableWidth = PdfConfig.USABLE_WIDTH + + @Test + fun `column X positions span the usable width with gaps between them`() { + val left = PageHeaderLayout.LEFT_X + val center = PageHeaderLayout.CENTER_X + val right = PageHeaderLayout.RIGHT_X + val width = PageHeaderLayout.COLUMN_WIDTH + + assertTrue(left < center) + assertTrue(center < right) + assertEquals(headerColumnGap.toFloat(), center - (left + width)) + assertEquals(headerColumnGap.toFloat(), right - (center + width)) + assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + } + + @Test + fun `compute places survey column labels and values at LEFT_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.LEFT_X, 0f), layout.leftColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.LEFT_X, 10f + lineSpacing), + layout.leftColumn.valueOffset, + ) + } + + @Test + fun `compute places job column labels and values at CENTER_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.CENTER_X, 0f), layout.centerColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.CENTER_X, 10f + lineSpacing), + layout.centerColumn.valueOffset, + ) + } + + @Test + fun `compute places timestamp at RIGHT_X with the same top as labels`() { + val layout = PageHeaderLayout.compute(top = 50f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.RIGHT_X, 50f), layout.rightTextOffset) + assertEquals(layout.leftColumn.labelOffset.y, layout.rightTextOffset.y) + assertEquals(layout.centerColumn.labelOffset.y, layout.rightTextOffset.y) + } + + @Test + fun `value sits below its label by exactly line spacing`() { + val labelHeight = 12f + val layout = PageHeaderLayout.compute(top = 30f, labelHeight = labelHeight, valueHeight = 14f) + + val survey = layout.leftColumn + assertEquals(labelHeight + lineSpacing, survey.valueOffset.y - survey.labelOffset.y) + } + + @Test + fun `nextCursorY accounts for label, line spacing, value, and header bottom gap`() { + val top = 40f + val labelHeight = 10f + val valueHeight = 14f + + val layout = PageHeaderLayout.compute(top = top, labelHeight, valueHeight) + + assertEquals( + top + labelHeight + lineSpacing + valueHeight + headerBottomGap, + layout.nextCursorY, + ) + } + + @Test + fun `all three columns share the same label baseline and value baseline`() { + val layout = PageHeaderLayout.compute(top = 100f, labelHeight = 12f, valueHeight = 16f) + + assertEquals(layout.leftColumn.labelOffset.y, layout.centerColumn.labelOffset.y) + assertEquals(layout.leftColumn.valueOffset.y, layout.centerColumn.valueOffset.y) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt new file mode 100644 index 0000000000..befaa6a137 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.groundplatform.feature.pdf.render.PdfConfig + +class QrBlockLayoutTest { + + private val margin = PdfConfig.MARGIN + private val pageWidth = PdfConfig.PAGE_WIDTH + private val qrSize = PdfConfig.QR_SIZE + private val lineSpacing = PdfConfig.LINE_SPACING + + private val expectedX = (pageWidth - margin - qrSize).toFloat() + + @Test + fun `QR frame is a square anchored at the right margin`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(expectedX, layout.qrFrame.x) + assertEquals(0f, layout.qrFrame.y) + assertEquals(qrSize.toFloat(), layout.qrFrame.width) + assertEquals(qrSize.toFloat(), layout.qrFrame.height) + assertEquals((pageWidth - margin).toFloat(), layout.qrFrame.right) + } + + @Test + fun `caption sits directly below the QR with line spacing between them`() { + val top = 100f + val layout = QrBlockLayout.compute(top = top, captionHeight = 10f) + + assertEquals(expectedX, layout.captionOffset.x) + assertEquals(top + qrSize + lineSpacing, layout.captionOffset.y) + } + + @Test + fun `caption maxWidth equals QR size so it stays under the QR image`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(qrSize, layout.captionMaxWidth) + } + + @Test + fun `caption shares its X with the QR frame`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(layout.qrFrame.x, layout.captionOffset.x) + } + + @Test + fun `nextCursorY accounts for QR, caption, and trailing spacing`() { + val top = 50f + val captionHeight = 14f + val layout = QrBlockLayout.compute(top = top, captionHeight = captionHeight) + + val expectedCaptionTop = top + qrSize + lineSpacing + assertEquals(expectedCaptionTop + captionHeight + lineSpacing * 2, layout.nextCursorY) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt new file mode 100644 index 0000000000..f8b54c350a --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset + +class TableRowLayoutTest { + + private val cellPadding = PdfConfig.CELL_PADDING.toFloat() + private val lineSpacing = PdfConfig.LINE_SPACING + private val margin = PdfConfig.MARGIN.toFloat() + private val usableWidth = PdfConfig.USABLE_WIDTH + private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH + + @Test + fun `totalHeight with only left text returns left height plus padding`() { + val height = + TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + + assertEquals(30f + 2 * cellPadding, height) + } + + @Test + fun `totalHeight picks the taller content height`() { + val tallerLeft = + TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + val tallerRight = + TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + val tallerImageRight = + TableRowLayout.totalHeight( + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(50f + 2 * cellPadding, tallerLeft) + assertEquals(60f + 2 * cellPadding, tallerRight) + assertEquals(80f + 2 * cellPadding, tallerImageRight) + } + + @Test + fun `totalHeight with both right text and image stacks them with line spacing`() { + val height = + TableRowLayout.totalHeight( + leftTextHeight = 10f, + rightTextHeight = 20f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(20f + lineSpacing + 80f + 2 * cellPadding, height) + } + + @Test + fun `compute always places left text at the row's top-left content area`() { + val layout = + TableRowLayout.compute( + rowTop = 100f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(PdfOffset(margin + cellPadding, 100f + cellPadding), layout.leftTextOffset) + } + + @Test + fun `compute returns null right offsets when right cell has no content`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertNull(layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places right text at the right cell's top`() { + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + assertEquals(PdfOffset(rightCellX, 50f + cellPadding), layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places image at the right cell's top`() { + val imageSize = PdfItemSize(width = 80f, height = 60f) + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = imageSize, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + val frame = assertNotNull(layout.rightImageFrame) + assertNull(layout.rightTextOffset) + with(frame) { + assertEquals(rightCellX, x) + assertEquals(50f + cellPadding, y) + assertEquals(imageSize.width, width) + assertEquals(imageSize.height, height) + } + } + + @Test + fun `compute sets row bounds and divider from page geometry`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(margin, layout.leftRowX) + assertEquals(margin + usableWidth, layout.rightRowX) + assertEquals(margin + taskColumnWidth, layout.columnDividerX) + assertTrue(layout.leftRowX < layout.columnDividerX) + assertTrue(layout.columnDividerX < layout.rightRowX) + } + + @Test + fun `compute totalHeight matches the static helper`() { + val left = 30f + val right = 20f + val image = PdfItemSize(width = 80f, height = 60f) + val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + + assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + } +} From 7b27c499760bb2d3a4780f78da8d8c6b93f596d4 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:59:54 +0200 Subject: [PATCH 24/50] update QrCodeGenerator to provide bitmap+logo for PDF documents --- .../qrcode/QrCodeGenerator.android.kt | 2 +- .../ui/components/qrcode/GroundQrCode.kt | 29 +------- .../ui/components/qrcode/QrCodeGenerator.kt | 68 ++++++++++++++++++- .../components/qrcode/QrCodeGenerator.ios.kt | 2 +- 4 files changed, 72 insertions(+), 29 deletions(-) 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/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt index 23b076a7c8..0b7f1783e4 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,29 +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. - * - * The composable is intentionally generic, it accepts any string payload, making it reusable for - * GeoJSON, URLs, or any other data that fits within QR code capacity limits. - */ @Composable fun GroundQrCode( modifier: Modifier = Modifier, @@ -80,12 +57,12 @@ fun GroundQrCode( centerLogoPainter: Painter?, footer: String, ) { - val contentBytes = remember(content) { content.encodeToByteArray().size } - val showLogo = centerLogoPainter != null && contentBytes <= MAX_QR_BYTES_WITH_LOGO + val fitsLogo = remember(content) { fitsLogoCapacity(content) } + val showLogo = centerLogoPainter != null && fitsLogo 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..94c0a829a9 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,74 @@ */ 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 +/** + * PDF document has more space to display the QR code, so we can use a larger fraction + */ +const val PDF_LOGO_SIZE_FRACTION = 0.25f + +/** + * 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 || !fitsLogoCapacity(content)) { + encodeQrBitmap(content, useHighEcc = false) + } else encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction) + +internal fun fitsLogoCapacity(content: String): Boolean = + content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO + + +/** 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/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() From 87d51a404157f57e4d04740802b25727544d1fc8 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:00:55 +0200 Subject: [PATCH 25/50] add android implementations for PDF platform interfaces --- feature/pdf/build.gradle.kts | 16 + .../feature/pdf/AndroidPdfImageProvider.kt | 163 ++++++++++ .../feature/pdf/AndroidPdfOutputProvider.kt | 43 +++ .../feature/pdf/AndroidPdfRenderer.kt | 72 +++++ .../feature/pdf/AndroidPdfReportLauncher.kt | 59 ++++ .../feature/pdf/render/DocumentPdfCanvas.kt | 55 ++++ .../feature/pdf/render/PdfCanvas.kt | 31 ++ .../feature/pdf/render/PdfTextPaints.kt | 24 ++ .../feature/pdf/render/PdfWriter.kt | 279 ++++++++++++++++++ .../feature/pdf/render/PdfCursor.kt | 46 +++ .../feature/pdf/render/PdfPageController.kt | 64 ++++ 11 files changed, 852 insertions(+) create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index 02b26cfcf9..cd7e2b192a 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -20,6 +20,7 @@ plugins { } kotlin { + jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) android { namespace = "org.groundplatform.feature.pdf" compileSdk { @@ -48,6 +49,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.ui) } } @@ -58,5 +60,19 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + + androidMain { + dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + } + } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + } } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt new file mode 100644 index 0000000000..95bf67e109 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import 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.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import kotlin.math.roundToInt +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.pointsToRenderPixels +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap + +/** + * Android implementation of [PdfImageProvider]. + * + * Bitmaps are decoded and scaled to their final on-page pixel size here so the renderer can draw + * them as-is without any further bitmap work. + * + * @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 { + + private val qrMaxPx = pointsToRenderPixels(PdfConfig.QR_SIZE.toFloat()) + private val photoMaxWidthPx = pointsToRenderPixels(PdfConfig.TABLE_ANSWER_TEXT_WIDTH.toFloat()) + private val photoMaxHeightPx = pointsToRenderPixels(PdfConfig.PHOTO_MAX_HEIGHT.toFloat()) + + 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() + .downscaledTo(qrMaxPx, qrMaxPx) + } + .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 decoded = runCatching { decodeSubsampled(file.absolutePath) }.getOrNull() ?: return null + val oriented = applyExifOrientation(file, decoded) + return oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + } + + /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ + private fun decodeSubsampled(path: String): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + // Orientation isn't known yet, so size against the larger target on both axes to be safe. + val target = maxOf(photoMaxWidthPx, photoMaxHeightPx) + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, target) + } + return BitmapFactory.decodeFile(path, options) + } + + /** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ + private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + var sampleSize = 1 + while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return sampleSize + } + + /** + * 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 + } +} + +/** + * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and + * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. + */ +private fun Bitmap.downscaledTo(maxWidthPx: Int, maxHeightPx: Int): Bitmap { + if (width <= maxWidthPx && height <= maxHeightPx) return this + val ratio = minOf(maxWidthPx.toFloat() / width, maxHeightPx.toFloat() / height) + val targetWidth = (width * ratio).roundToInt().coerceAtLeast(1) + val targetHeight = (height * ratio).roundToInt().coerceAtLeast(1) + + var current = this + var w = width + var h = height + while (w / 2 >= targetWidth && h / 2 >= targetHeight) { + w /= 2 + h /= 2 + val halved = current.scale(w, h) + if (current !== this) current.recycle() + current = halved + } + val result = current.scale(targetWidth, targetHeight) + if (current !== this) current.recycle() + return result +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..1ae8029940 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/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.feature.pdf + +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/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt new file mode 100644 index 0000000000..d237510484 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.graphics.pdf.PdfDocument +import java.io.File +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.DocumentPdfCanvas +import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas +import org.groundplatform.feature.pdf.render.PdfCanvas +import org.groundplatform.feature.pdf.render.PdfWriter +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just + * counts pages. + */ +class AndroidPdfRenderer : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + // Measurement first so the footer can show "page/total" + val totalPages = measurePageCount(document, images) + val pdf = PdfDocument() + try { + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages).draw(document) + File(outputPath).outputStream().use { pdf.writeTo(it) } + } finally { + pdf.close() + } + } + + private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = + writer(document, images, MeasurementPdfCanvas, totalPages = null).draw(document).pageCount + + private fun writer( + document: SubmissionPdfDocument, + images: PdfImageSet, + pdfCanvas: PdfCanvas, + totalPages: Int?, + ): PdfWriter = + PdfWriter( + pdfCanvas = pdfCanvas, + images = images, + header = document.header, + footer = document.footer, + totalPages = totalPages, + ) + + private fun PdfWriter.draw(document: SubmissionPdfDocument): PdfWriter = apply { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..80f294da51 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** 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) + } + + companion object { + private const val PDF_MIME_TYPE = "application/pdf" + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt new file mode 100644 index 0000000000..5bbebca4f0 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -0,0 +1,55 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import androidx.core.graphics.withTranslation +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** + * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * to arrive at their on-page pixel size; the canvas does no further scaling. + */ +internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { + private var currentPage: PdfDocument.Page? = null + + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = PdfConfig.BORDER_WIDTH + isAntiAlias = true + } + + private val smoothImagePaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + override fun startPage(pageNumber: Int) { + val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + currentPage = pdf.startPage(info) + } + + override fun finishPage() { + currentPage?.also { pdf.finishPage(it) } + currentPage = null + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + canvas().drawBitmap(image.bitmap, null, frame, if (smoothScaling) smoothImagePaint else null) + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + canvas().drawLine(x1, y1, x2, y2, strokePaint) + } + + private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt new file mode 100644 index 0000000000..3fa449022e --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -0,0 +1,31 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** Abstraction for drawing onto a PDF page. */ +internal interface PdfCanvas { + fun startPage(pageNumber: Int) + + fun finishPage() + + fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) + + fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) + + fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) +} + +/** Used during the page-counting phase. Drops every drawing call. */ +internal object MeasurementPdfCanvas : PdfCanvas { + override fun startPage(pageNumber: Int) = Unit + + override fun finishPage() = Unit + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) = Unit + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt new file mode 100644 index 0000000000..f60a0f41a9 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -0,0 +1,24 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.PdfConfig.BODY_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.CAPTION_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.TITLE_SIZE + +internal class PdfTextPaints { + val title: TextPaint = textPaint(TITLE_SIZE, bold = false) + val body: TextPaint = textPaint(BODY_SIZE, bold = false) + val metaLabel: TextPaint = textPaint(CAPTION_SIZE, bold = true, textColor = Color.GRAY) + val meta: TextPaint = textPaint(CAPTION_SIZE, bold = false, textColor = Color.GRAY) + val caption: TextPaint = textPaint(CAPTION_SIZE, bold = false) + + private fun textPaint(size: Float, bold: Boolean, textColor: Int = Color.BLACK): TextPaint = + TextPaint().apply { + textSize = size + color = textColor + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt new file mode 100644 index 0000000000..dabba56a6f --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -0,0 +1,279 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.StyleSpan +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.QR_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.components.PageFooterLayout +import org.groundplatform.feature.pdf.render.components.PageHeaderLayout +import org.groundplatform.feature.pdf.render.components.QrBlockLayout +import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [PdfCursor], shared paints) shared by all + * sections. + */ +internal class PdfWriter( + private val pdfCanvas: PdfCanvas, + private val images: PdfImageSet, + private val totalPages: Int? = null, + private val header: Header, + footer: Footer, +) : PdfPageController.PageLifecycle { + private val paints = PdfTextPaints() + + private val cursor = PdfCursor() + private val pageController = PdfPageController(cursor, this) + + private var currentTableTopY: Float? = null + + private val footerLayout: StaticLayout + + init { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + footerLayout = + staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP + } + + val pageCount: Int + get() = pageController.pageCount + + override fun onPageStarted(pageNumber: Int) { + pdfCanvas.startPage(pageNumber) + drawPageHeader() + } + + override fun onPageEnding(pageNumber: Int) { + flushTableDivider() + drawPageFooter() + pdfCanvas.finishPage() + } + + fun drawQrBlock(block: QrBlock) { + val qr = images[PdfImageSet.ImageRef.Qr] ?: return + pageController.ensurePage() + val captionLayout = + staticLayout(block.scanCaption, paints.caption, QR_SIZE, Layout.Alignment.ALIGN_CENTER) + val layout = + QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) + drawImage(qr, layout.qrFrame, smoothScaling = false) + drawStaticLayoutAt(captionLayout, layout.captionOffset) + cursor.moveTo(layout.nextCursorY) + } + + fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + pageController.ensurePage() + val x = MARGIN.toFloat() + cursor.advance(LINE_SPACING * 2) + val label = + SpannableString("${table.submissionLabel}: ${table.loiName}").apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + table.submissionLabel.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) + cursor.advance(LINE_SPACING) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> + drawTableRow( + questionText = row.question, + answerText = answer.lines.joinToString("\n"), + photo = null, + ) + is Answer.Photo -> + drawTableRow( + questionText = row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + flushTableDivider() + } + + fun finalizePage() { + pageController.finalizePage() + } + + private fun drawPageHeader() { + val columnWidth = PageHeaderLayout.COLUMN_WIDTH + val surveyLabel = staticLayout(header.surveyLabel, paints.metaLabel, columnWidth) + val surveyValue = + staticLayout(header.surveyName, paints.meta, columnWidth, maxLines = MAX_HEADER_VALUE_LINES) + val jobLabel = + staticLayout(header.jobLabel, paints.metaLabel, columnWidth, Layout.Alignment.ALIGN_CENTER) + val jobValue = + staticLayout( + header.jobName, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = MAX_HEADER_VALUE_LINES, + ) + val timestamp = + staticLayout( + header.timestamp, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = MAX_HEADER_VALUE_LINES, + ) + + val layout = + PageHeaderLayout.compute( + top = cursor.y, + labelHeight = surveyLabel.height.toFloat(), + valueHeight = surveyValue.height.toFloat(), + ) + + drawStaticLayoutAt(surveyLabel, layout.leftColumn.labelOffset) + drawStaticLayoutAt(surveyValue, layout.leftColumn.valueOffset) + drawStaticLayoutAt(jobLabel, layout.centerColumn.labelOffset) + drawStaticLayoutAt(jobValue, layout.centerColumn.valueOffset) + drawStaticLayoutAt(timestamp, layout.rightTextOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawPageFooter() { + val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) + drawStaticLayoutAt(footerLayout, layout.footerTextOffset) + totalPages?.let { total -> + drawText( + text = "${pageController.pageCount}/$total", + x = layout.pageNumberOffset.x, + y = layout.pageNumberOffset.y, + maxWidth = layout.pageNumberMaxWidth, + paint = paints.meta, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + } + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, paints.body, PdfConfig.TABLE_TASK_TEXT_WIDTH) + val answerLayout = + if (answerText.isEmpty()) null + else staticLayout(answerText, paints.body, PdfConfig.TABLE_ANSWER_TEXT_WIDTH) + val photoSize = photo?.let { + fitInside(it.width, it.height, PdfConfig.TABLE_ANSWER_TEXT_WIDTH, PHOTO_MAX_HEIGHT) + } + + val questionHeight = questionLayout.height.toFloat() + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + pageController.newPageIfShort( + TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) + ) + val rowLayout = + TableRowLayout.compute( + rowTop = cursor.y, + leftTextHeight = questionHeight, + rightTextHeight = answerHeight, + rightImageSize = photoSize, + ) + + if (currentTableTopY == null) { + currentTableTopY = cursor.y + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) + if (answerLayout != null && rowLayout.rightTextOffset != null) { + drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) + } + if (photo != null && rowLayout.rightImageFrame != null) { + drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) + } + cursor.advance(rowLayout.totalHeight) + + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + private fun flushTableDivider() { + val top = currentTableTopY ?: return + val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() + pdfCanvas.drawLine(midX, top, midX, cursor.y) + currentTableTopY = null + } + + /** + * Lays out [text] and draws it at ([x], [y]). + * + * @return the Y just below the drawn text. + */ + private fun drawText( + text: CharSequence, + 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) + pdfCanvas.drawStaticLayout(layout, x, y) + return y + layout.height + } + + private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = + pdfCanvas.drawStaticLayout(layout, offset.x, offset.y) + + private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = + pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + + /** + * 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: CharSequence, + 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() +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt new file mode 100644 index 0000000000..2c67ea1f02 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class PdfCursor( + private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, + private val margin: Int = PdfConfig.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 + + val isAtPageTop: Boolean + get() = y == margin.toFloat() + + 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt new file mode 100644 index 0000000000..b256481cb7 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** + * Platform-agnostic page state machine for PDF rendering. Delegates the actual page allocation and + * drawing to a platform-specific [PageLifecycle] implementation. + */ +internal class PdfPageController( + private val cursor: PdfCursor, + private val lifecycle: PageLifecycle, +) { + interface PageLifecycle { + /** Called after a new page has been allocated. The header should be drawn here. */ + fun onPageStarted(pageNumber: Int) + + /** Called before the page is closed. The footer and per-page flush should happen here. */ + fun onPageEnding(pageNumber: Int) + } + + private var pageIndex = 0 + private var pageOpen = false + + /** Number of pages emitted so far. Equals the current page number while a page is open. */ + val pageCount: Int + get() = pageIndex + + fun ensurePage() { + if (!pageOpen) beginPage() + } + + fun newPageIfShort(spaceNeeded: Float) { + ensurePage() + if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return + finalizePage() + beginPage() + } + + fun finalizePage() { + if (!pageOpen) return + lifecycle.onPageEnding(pageIndex) + pageOpen = false + } + + private fun beginPage() { + pageIndex++ + pageOpen = true + cursor.reset() + lifecycle.onPageStarted(pageIndex) + } +} From dcbca6f318308047bca3ed80a06e9e3a8990b176 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:01:20 +0200 Subject: [PATCH 26/50] add unit tests for PdfCursor and PdfPageController --- .../feature/pdf/render/PdfCursorTest.kt | 123 ++++++++++++ .../pdf/render/PdfPageControllerTest.kt | 176 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 301 insertions(+) create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt new file mode 100644 index 0000000000..06bf9f85d9 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfCursorTest { + + @Test + fun `fresh cursor starts at the top margin`() { + val cursor = PdfCursor() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `advance moves the cursor down by the given delta`() { + val cursor = PdfCursor() + val start = cursor.y + + cursor.advance(75f) + + assertEquals(start + 75f, cursor.y) + assertFalse(cursor.isAtPageTop) + } + + @Test + fun `moveTo sets the cursor to an absolute Y`() { + val cursor = PdfCursor() + + cursor.moveTo(400f) + + assertEquals(400f, cursor.y) + } + + @Test + fun `reset returns the cursor to the top margin`() { + val cursor = PdfCursor() + cursor.advance(300f) + + cursor.reset() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `isAtPageTop reflects whether Y matches the top margin`() { + val cursor = PdfCursor() + assertTrue(cursor.isAtPageTop) + + cursor.advance(1f) + assertFalse(cursor.isAtPageTop) + + cursor.moveTo(PdfConfig.MARGIN.toFloat()) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `fits returns true when there is room above the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertTrue(cursor.fits(available.toFloat())) + assertTrue(cursor.fits(10f)) + } + + @Test + fun `fits returns false when the requested height overflows the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertFalse(cursor.fits(available + 1f)) + } + + @Test + fun `fits subtracts the footer reserve from the available space`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + cursor.footerReserve = 50f + + assertTrue(cursor.fits(available - 50f)) + assertFalse(cursor.fits(available - 49f)) + } + + @Test + fun `fits depends on the current Y position`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + cursor.advance(100f) + + assertTrue(cursor.fits(available - 100f)) + assertFalse(cursor.fits(available - 99f)) + } + + @Test + fun `custom page height and margin are respected`() { + val cursor = PdfCursor(pageHeight = 200, margin = 10) + + assertEquals(10f, cursor.y) + // Usable height = 200 - 2*10 = 180. + assertTrue(cursor.fits(180f)) + assertFalse(cursor.fits(181f)) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt new file mode 100644 index 0000000000..dccda1cf90 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfPageControllerTest { + + private val lifecycle = + object : PdfPageController.PageLifecycle { + val events: MutableList = mutableListOf() + + override fun onPageStarted(pageNumber: Int) { + events += PageEvent.Started(pageNumber) + } + + override fun onPageEnding(pageNumber: Int) { + events += PageEvent.Ending(pageNumber) + } + } + private val cursor = PdfCursor() + private val controller = PdfPageController(cursor, lifecycle) + + @Test + fun `Should have zero pages at the start`() { + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `ensurePage starts the a page`() { + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `ensurePage is idempotent while the page is open`() { + controller.ensurePage() + controller.ensurePage() + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `finalizePage does nothing if there is no page open`() { + controller.finalizePage() + + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `finalizePage does nothing when the page is already closed`() { + controller.ensurePage() + controller.finalizePage() + controller.finalizePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `ensurePage followed by finalize emits start and end events`() { + controller.ensurePage() + controller.finalizePage() + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort starts a page if there is none open`() { + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does not emit more pages if impossible to fit content in a new page`() { + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does nothing if the content fits in the current page`() { + controller.ensurePage() + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `newPageIfShort starts a new page if the content overflows`() { + controller.ensurePage() + cursor.advance(100f) + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(2, controller.pageCount) + assertEquals(listOf(PageEvent.Ending(1), PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `newPageIfShort should set the cursor at the start of the new page`() { + controller.ensurePage() + cursor.advance(500f) + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + } + + @Test + fun `Adding multiple pages emits the correct start and end events`() { + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + controller.finalizePage() + + assertEquals(3, controller.pageCount) + assertEquals( + listOf( + PageEvent.Started(1), + PageEvent.Ending(1), + PageEvent.Started(2), + PageEvent.Ending(2), + PageEvent.Started(3), + PageEvent.Ending(3), + ), + lifecycle.events, + ) + } + + @Test + fun `pageCount reflects the current page number while the page is open`() { + controller.ensurePage() + assertEquals(1, controller.pageCount) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + assertEquals(2, controller.pageCount) + } + + private sealed interface PageEvent { + data class Started(val pageNumber: Int) : PageEvent + + data class Ending(val pageNumber: Int) : PageEvent + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b7d853b7a..2a0b445664 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,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.14.1" firebaseCrashlyticsGradleVersion = "3.0.7" @@ -91,6 +92,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 5a163df6c26834dee8b586a9669bebcf14c383ad Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 3 Jun 2026 18:33:39 +0200 Subject: [PATCH 27/50] extract common logic for the footer reserve and table building --- feature/pdf/build.gradle.kts | 2 +- .../feature/pdf/render/DocumentPdfCanvas.kt | 22 +++- .../feature/pdf/render/PdfCanvas.kt | 17 ++- .../feature/pdf/render/PdfTextPaints.kt | 15 +++ .../feature/pdf/render/PdfWriter.kt | 119 +++++++---------- .../feature/pdf/render/PdfCursor.kt | 7 +- .../feature/pdf/render/PdfGeometry.kt | 2 + .../pdf/render/components/PageFooterLayout.kt | 8 ++ .../pdf/render/components/TableLayout.kt | 112 ++++++++++++++++ .../pdf/render/components/TableRowLayout.kt | 90 ------------- .../feature/pdf/render/PdfCursorTest.kt | 27 ++-- .../pdf/render/PdfPageControllerTest.kt | 2 +- .../render/components/PageFooterLayoutTest.kt | 7 + .../render/components/PageHeaderLayoutTest.kt | 2 +- ...bleRowLayoutTest.kt => TableLayoutTest.kt} | 121 ++++++++++++++---- 15 files changed, 348 insertions(+), 205 deletions(-) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt delete mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt rename feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/{TableRowLayoutTest.kt => TableLayoutTest.kt} (51%) diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index cd7e2b192a..a3338a1368 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -49,7 +49,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) - implementation(libs.compose.ui) } } @@ -65,6 +64,7 @@ kotlin { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.exifinterface) + implementation(libs.compose.ui) } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt index 5bbebca4f0..069da903a8 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.Canvas @@ -9,7 +24,7 @@ import androidx.core.graphics.withTranslation import org.groundplatform.feature.pdf.render.image.PdfImage /** - * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * [PdfCanvas] that draws onto a real [PdfDocument], one page at a time. Image bitmaps are expected * to arrive at their on-page pixel size; the canvas does no further scaling. */ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { @@ -30,7 +45,8 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } override fun startPage(pageNumber: Int) { - val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + val info = + PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() currentPage = pdf.startPage(info) } @@ -52,4 +68,4 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") -} \ No newline at end of file +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt index 3fa449022e..8f532c7a94 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.RectF @@ -28,4 +43,4 @@ internal object MeasurementPdfCanvas : PdfCanvas { override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit -} \ No newline at end of file +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt index f60a0f41a9..25b8c3913e 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.Color diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index dabba56a6f..ebe78bfb38 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.groundplatform.feature.pdf.render import android.graphics.RectF @@ -16,9 +31,7 @@ import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT @@ -27,7 +40,7 @@ import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH import org.groundplatform.feature.pdf.render.components.PageFooterLayout import org.groundplatform.feature.pdf.render.components.PageHeaderLayout import org.groundplatform.feature.pdf.render.components.QrBlockLayout -import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.components.TableLayout import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet @@ -45,24 +58,11 @@ internal class PdfWriter( ) : PdfPageController.PageLifecycle { private val paints = PdfTextPaints() - private val cursor = PdfCursor() + private val footerLayout: StaticLayout = buildFooterLayout(footer) + private val cursor = + PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) private val pageController = PdfPageController(cursor, this) - private var currentTableTopY: Float? = null - - private val footerLayout: StaticLayout - - init { - val footerLabel = footer.dataCollectorLabel - val footerText = - SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { - setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - footerLayout = - staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) - cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP - } - val pageCount: Int get() = pageController.pageCount @@ -72,7 +72,6 @@ internal class PdfWriter( } override fun onPageEnding(pageNumber: Int) { - flushTableDivider() drawPageFooter() pdfCanvas.finishPage() } @@ -92,8 +91,6 @@ internal class PdfWriter( fun drawTable(table: SubmissionPdfDocument.Table) { val rows = table.rows.takeIf { it.isNotEmpty() } ?: return pageController.ensurePage() - val x = MARGIN.toFloat() - cursor.advance(LINE_SPACING * 2) val label = SpannableString("${table.submissionLabel}: ${table.loiName}").apply { setSpan( @@ -103,8 +100,10 @@ internal class PdfWriter( Spanned.SPAN_INCLUSIVE_EXCLUSIVE, ) } - cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) - cursor.advance(LINE_SPACING) + val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) + val tableLabel = TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) + cursor.moveTo(tableLabel.nextCursorY) rows.forEach { row -> when (val answer = row.answer) { is Answer.Text -> @@ -121,7 +120,6 @@ internal class PdfWriter( ) } } - flushTableDivider() } fun finalizePage() { @@ -171,15 +169,15 @@ internal class PdfWriter( val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) drawStaticLayoutAt(footerLayout, layout.footerTextOffset) totalPages?.let { total -> - drawText( - text = "${pageController.pageCount}/$total", - x = layout.pageNumberOffset.x, - y = layout.pageNumberOffset.y, - maxWidth = layout.pageNumberMaxWidth, - paint = paints.meta, - alignment = Layout.Alignment.ALIGN_OPPOSITE, - maxLines = 1, - ) + val pageNumber = + staticLayout( + "${pageController.pageCount}/$total", + paints.meta, + layout.pageNumberMaxWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + drawStaticLayoutAt(pageNumber, layout.pageNumberOffset) } } @@ -194,22 +192,16 @@ internal class PdfWriter( val questionHeight = questionLayout.height.toFloat() val answerHeight = answerLayout?.height?.toFloat() ?: 0f - pageController.newPageIfShort( - TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) - ) + pageController.newPageIfShort(TableLayout.getRowHeight(questionHeight, answerHeight, photoSize)) val rowLayout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = cursor.y, leftTextHeight = questionHeight, rightTextHeight = answerHeight, rightImageSize = photoSize, ) - if (currentTableTopY == null) { - currentTableTopY = cursor.y - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - + rowLayout.borderLines.forEach { drawLine(it) } drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) if (answerLayout != null && rowLayout.rightTextOffset != null) { drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) @@ -218,35 +210,6 @@ internal class PdfWriter( drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) } cursor.advance(rowLayout.totalHeight) - - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - - private fun flushTableDivider() { - val top = currentTableTopY ?: return - val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() - pdfCanvas.drawLine(midX, top, midX, cursor.y) - currentTableTopY = null - } - - /** - * Lays out [text] and draws it at ([x], [y]). - * - * @return the Y just below the drawn text. - */ - private fun drawText( - text: CharSequence, - 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) - pdfCanvas.drawStaticLayout(layout, x, y) - return y + layout.height } private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = @@ -255,6 +218,18 @@ internal class PdfWriter( private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + private fun drawLine(line: PdfLine) = + pdfCanvas.drawLine(line.startX, line.startY, line.endX, line.endY) + + private fun buildFooterLayout(footer: Footer): StaticLayout { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + return staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + } + /** * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a * single long value can't grow the layout unboundedly. diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index 2c67ea1f02..ccd2b8139c 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,12 +17,13 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( + /** + * Space kept clear above the bottom margin for the footer. + */ + private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.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 diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt index 40b0fc8ce0..3b55da2103 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -30,6 +30,8 @@ internal data class PdfItemSize(val width: Float, val height: Float) internal data class PdfOffset(val x: Float, val y: Float) +internal data class PdfLine(val startX: Float, val startY: Float, val endX: Float, val endY: Float) + /** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { val right: Float diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt index adf5b3976d..9abf5498de 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -16,6 +16,7 @@ package org.groundplatform.feature.pdf.render.components import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH @@ -37,6 +38,13 @@ internal data class PageFooterLayout( val pageNumberMaxWidth: Int, ) { companion object { + /** + * Vertical space the footer occupies, including the [FOOTER_TOP_GAP] separating it from page + * content. Feed this to [org.groundplatform.feature.pdf.render.PdfCursor] so pagination keeps + * the footer clear of content. + */ + fun reserve(footerHeight: Float): Float = footerHeight + FOOTER_TOP_GAP + fun compute(footerHeight: Float): PageFooterLayout { val top = PAGE_HEIGHT - MARGIN - footerHeight val left = MARGIN.toFloat() diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt new file mode 100644 index 0000000000..be1d885ae6 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect +internal object TableLayout { + /** + * Layout of a single two-column table row. Left cell holds a single text block; right cell may + * contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no + * image. + * @param borderLines The row's own frame: top border, bottom border, and column divider. + */ + data class Row( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, + val borderLines: List, + ) + + data class Label(val labelOffset: PdfOffset, val nextCursorY: Float) + + fun getLabel(top: Float, labelHeight: Float): Label { + val labelTop = top + LINE_SPACING * 2 + return Label( + labelOffset = PdfOffset(MARGIN.toFloat(), labelTop), + nextCursorY = labelTop + labelHeight + LINE_SPACING, + ) + } + + /** Row height for the page-fit check, before the final row position is known. */ + fun getRowHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun getRow( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Row { + val totalHeight = getRowHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val right = left + USABLE_WIDTH + val midX = left + TABLE_TASK_COLUMN_WIDTH + val rowBottom = rowTop + totalHeight + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return Row( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = right, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + borderLines = + listOf( + PdfLine(startX = left, startY = rowTop, endX = right, endY = rowTop), + PdfLine(startX = left, startY = rowBottom, endX = right, endY = rowBottom), + PdfLine(startX = midX, startY = rowTop, endX = midX, endY = rowBottom), + ), + ) + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt deleted file mode 100644 index cf7e2de67c..0000000000 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.feature.pdf.render.components - -import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING -import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN -import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH -import org.groundplatform.feature.pdf.render.PdfItemSize -import org.groundplatform.feature.pdf.render.PdfOffset -import org.groundplatform.feature.pdf.render.PdfRect - -/** - * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell - * may contain either text or an image. - * - * @param totalHeight Total height of the row including vertical padding. - * @param leftRowX X coordinate of the row's left edge. - * @param rightRowX X coordinate of the row's right edge. - * @param columnDividerX X coordinate of the vertical divider between the two columns. - * @param leftTextOffset Top-left position where the left cell text should be drawn. - * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if - * the right cell has no text. - * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. - */ -internal data class TableRowLayout( - val totalHeight: Float, - val leftRowX: Float, - val rightRowX: Float, - val columnDividerX: Float, - val leftTextOffset: PdfOffset, - val rightTextOffset: PdfOffset?, - val rightImageFrame: PdfRect?, -) { - companion object { - fun totalHeight( - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): Float { - val imageHeight = rightImageSize?.height ?: 0f - val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f - return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING - } - - fun compute( - rowTop: Float, - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): TableRowLayout { - val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) - - val left = MARGIN.toFloat() - val midX = left + TABLE_TASK_COLUMN_WIDTH - val contentTop = rowTop + CELL_PADDING - val rightCellLeft = midX + CELL_PADDING - - val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null - val rightImageFrame = rightImageSize?.let { - val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) - PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) - } - - return TableRowLayout( - totalHeight = totalHeight, - leftRowX = left, - rightRowX = left + USABLE_WIDTH, - columnDividerX = midX, - leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), - rightTextOffset = rightTextOffset, - rightImageFrame = rightImageFrame, - ) - } - } -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt index 06bf9f85d9..263343b1bd 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -24,7 +24,7 @@ class PdfCursorTest { @Test fun `fresh cursor starts at the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) assertTrue(cursor.isAtPageTop) @@ -32,7 +32,7 @@ class PdfCursorTest { @Test fun `advance moves the cursor down by the given delta`() { - val cursor = PdfCursor() + val cursor = newCursor() val start = cursor.y cursor.advance(75f) @@ -43,7 +43,7 @@ class PdfCursorTest { @Test fun `moveTo sets the cursor to an absolute Y`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.moveTo(400f) @@ -52,7 +52,7 @@ class PdfCursorTest { @Test fun `reset returns the cursor to the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.advance(300f) cursor.reset() @@ -63,7 +63,7 @@ class PdfCursorTest { @Test fun `isAtPageTop reflects whether Y matches the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertTrue(cursor.isAtPageTop) cursor.advance(1f) @@ -75,7 +75,7 @@ class PdfCursorTest { @Test fun `fits returns true when there is room above the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertTrue(cursor.fits(available.toFloat())) @@ -84,7 +84,7 @@ class PdfCursorTest { @Test fun `fits returns false when the requested height overflows the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertFalse(cursor.fits(available + 1f)) @@ -92,9 +92,8 @@ class PdfCursorTest { @Test fun `fits subtracts the footer reserve from the available space`() { - val cursor = PdfCursor() + val cursor = newCursor(footerReserve = 50f) val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() - cursor.footerReserve = 50f assertTrue(cursor.fits(available - 50f)) assertFalse(cursor.fits(available - 49f)) @@ -102,7 +101,7 @@ class PdfCursorTest { @Test fun `fits depends on the current Y position`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() cursor.advance(100f) @@ -113,11 +112,17 @@ class PdfCursorTest { @Test fun `custom page height and margin are respected`() { - val cursor = PdfCursor(pageHeight = 200, margin = 10) + val cursor = newCursor(pageHeight = 200, margin = 10) assertEquals(10f, cursor.y) // Usable height = 200 - 2*10 = 180. assertTrue(cursor.fits(180f)) assertFalse(cursor.fits(181f)) } + + private fun newCursor( + footerReserve: Float = 0f, + pageHeight: Int = PdfConfig.PAGE_HEIGHT, + margin: Int = PdfConfig.MARGIN, + ) = PdfCursor(footerReserve = footerReserve, pageHeight = pageHeight, margin = margin) } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt index dccda1cf90..4ad96927a6 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -33,7 +33,7 @@ class PdfPageControllerTest { events += PageEvent.Ending(pageNumber) } } - private val cursor = PdfCursor() + private val cursor = PdfCursor(footerReserve = 0f) private val controller = PdfPageController(cursor, lifecycle) @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt index e15a3f4c36..8b56b1b98b 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -88,4 +88,11 @@ class PageFooterLayoutTest { assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) } + + @Test + fun `reserve adds the top gap to the footer height`() { + val footerHeight = 12f + + assertEquals(footerHeight + PdfConfig.FOOTER_TOP_GAP, PageFooterLayout.reserve(footerHeight)) + } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt index 792fa0e62d..b15573a95a 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -40,7 +40,7 @@ class PageHeaderLayoutTest { assertTrue(center < right) assertEquals(headerColumnGap.toFloat(), center - (left + width)) assertEquals(headerColumnGap.toFloat(), right - (center + width)) - assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + assertTrue(3 * width + 2 * headerColumnGap <= usableWidth) } @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt similarity index 51% rename from feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt index f8b54c350a..287a7341da 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt @@ -22,9 +22,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.groundplatform.feature.pdf.render.PdfConfig import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset -class TableRowLayoutTest { +class TableLayoutTest { private val cellPadding = PdfConfig.CELL_PADDING.toFloat() private val lineSpacing = PdfConfig.LINE_SPACING @@ -33,21 +34,47 @@ class TableRowLayoutTest { private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH @Test - fun `totalHeight with only left text returns left height plus padding`() { + fun `label sits below a top gap at the left margin`() { + val layout = TableLayout.getLabel(top = 100f, labelHeight = 14f) + + assertEquals(PdfOffset(margin, 100f + 2 * lineSpacing), layout.labelOffset) + } + + @Test + fun `label leaves a bottom gap before the first row`() { + val top = 100f + val labelHeight = 14f + + val layout = TableLayout.getLabel(top = top, labelHeight = labelHeight) + + assertEquals(top + 2 * lineSpacing + labelHeight + lineSpacing, layout.nextCursorY) + } + + @Test + fun `taller label pushes the first row further down`() { + val short = TableLayout.getLabel(top = 0f, labelHeight = 10f) + val tall = TableLayout.getLabel(top = 0f, labelHeight = 30f) + + assertTrue(short.nextCursorY < tall.nextCursorY) + assertEquals(20f, tall.nextCursorY - short.nextCursorY) + } + + @Test + fun `rowHeight with only left text returns left height plus padding`() { val height = - TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) assertEquals(30f + 2 * cellPadding, height) } @Test - fun `totalHeight picks the taller content height`() { + fun `rowHeight picks the taller content height`() { val tallerLeft = - TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) val tallerRight = - TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) val tallerImageRight = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 20f, rightTextHeight = 0f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -59,9 +86,9 @@ class TableRowLayoutTest { } @Test - fun `totalHeight with both right text and image stacks them with line spacing`() { + fun `rowHeight with both right text and image stacks them with line spacing`() { val height = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 10f, rightTextHeight = 20f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -71,9 +98,9 @@ class TableRowLayoutTest { } @Test - fun `compute always places left text at the row's top-left content area`() { + fun `row always places left text at the row's top-left content area`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 100f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -84,9 +111,9 @@ class TableRowLayoutTest { } @Test - fun `compute returns null right offsets when right cell has no content`() { + fun `row returns null right offsets when right cell has no content`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -98,9 +125,9 @@ class TableRowLayoutTest { } @Test - fun `compute places right text at the right cell's top`() { + fun `row places right text at the right cell's top`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 20f, @@ -113,10 +140,10 @@ class TableRowLayoutTest { } @Test - fun `compute places image at the right cell's top`() { + fun `row places image at the right cell's top`() { val imageSize = PdfItemSize(width = 80f, height = 60f) val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -135,9 +162,9 @@ class TableRowLayoutTest { } @Test - fun `compute sets row bounds and divider from page geometry`() { + fun `row sets bounds and divider from page geometry`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -152,12 +179,62 @@ class TableRowLayoutTest { } @Test - fun `compute totalHeight matches the static helper`() { + fun `row frames itself with top, bottom, and column-divider border lines`() { + val rowTop = 100f + val layout = + TableLayout.getRow( + rowTop = rowTop, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rowBottom = rowTop + layout.totalHeight + val right = margin + usableWidth + val midX = margin + taskColumnWidth + assertEquals( + listOf( + PdfLine(margin, rowTop, right, rowTop), + PdfLine(margin, rowBottom, right, rowBottom), + PdfLine(midX, rowTop, midX, rowBottom), + ), + layout.borderLines, + ) + } + + @Test + fun `consecutive rows produce abutting borders so the divider reads as one continuous line`() { + val first = + TableLayout.getRow( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + val second = + TableLayout.getRow( + rowTop = 50f + first.totalHeight, + leftTextHeight = 30f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + // The first row's bottom border sits exactly where the second row's top border begins. + assertEquals(first.borderLines[1].startY, second.borderLines[0].startY) + // The per-row divider segments share an X and meet end-to-start, forming one unbroken line. + val firstDivider = first.borderLines[2] + val secondDivider = second.borderLines[2] + assertEquals(firstDivider.endX, secondDivider.startX) + assertEquals(firstDivider.endY, secondDivider.startY) + } + + @Test + fun `row totalHeight matches the rowHeight helper`() { val left = 30f val right = 20f val image = PdfItemSize(width = 80f, height = 60f) - val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + val layout = TableLayout.getRow(rowTop = 0f, left, right, image) - assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + assertEquals(TableLayout.getRowHeight(left, right, image), layout.totalHeight) } } From 90a1d81a45039543c6d0c8e36bca17bf92254b1f Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 15:07:22 +0200 Subject: [PATCH 28/50] fix code formatting --- .../ui/components/qrcode/QrCodeGenerator.kt | 5 +--- .../feature/pdf/AndroidPdfImageProvider.kt | 30 +++++++++++-------- .../feature/pdf/AndroidPdfRenderer.kt | 2 +- .../feature/pdf/render/PdfCursor.kt | 4 +-- .../pdf/render/components/TableLayout.kt | 1 + 5 files changed, 21 insertions(+), 21 deletions(-) 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 94c0a829a9..3e29152b76 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 @@ -41,9 +41,7 @@ const val MAX_QR_BYTES_WITH_LOGO = 1000 * high error correction (ECC level H), which can tolerate approximately 30% data loss. */ const val LOGO_SIZE_FRACTION = 0.15f -/** - * PDF document has more space to display the QR code, so we can use a larger fraction - */ +/** PDF document has more space to display the QR code, so we can use a larger fraction. */ const val PDF_LOGO_SIZE_FRACTION = 0.25f /** @@ -68,7 +66,6 @@ fun generateQrBitmap( internal fun fitsLogoCapacity(content: String): Boolean = content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO - /** 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) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 95bf67e109..5472474fb9 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -92,10 +92,14 @@ class AndroidPdfImageProvider( val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null val filename = remoteFilename.substringAfterLast('/') val file = File(rootDir, filename) - if (!file.exists()) return null - val decoded = runCatching { decodeSubsampled(file.absolutePath) }.getOrNull() ?: return null - val oriented = applyExifOrientation(file, decoded) - return oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + return if (file.exists()) { + runCatching { decodeSubsampled(file.absolutePath) } + .getOrNull() + ?.let { decodedBitmap -> + val oriented = applyExifOrientation(file, decodedBitmap) + oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + } + } else null } /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ @@ -111,15 +115,6 @@ class AndroidPdfImageProvider( return BitmapFactory.decodeFile(path, options) } - /** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ - private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { - var sampleSize = 1 - while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { - sampleSize *= 2 - } - return sampleSize - } - /** * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no * rotation is needed. @@ -137,6 +132,15 @@ class AndroidPdfImageProvider( } } +/** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ +internal fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + var sampleSize = 1 + while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return sampleSize +} + /** * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index d237510484..ef5670dfc5 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -69,4 +69,4 @@ class AndroidPdfRenderer : PdfRenderer { drawTable(document.table) finalizePage() } -} \ No newline at end of file +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index ccd2b8139c..4559f1e115 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,9 +17,7 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( - /** - * Space kept clear above the bottom margin for the footer. - */ + /** Space kept clear above the bottom margin for the footer. */ private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.MARGIN, diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt index be1d885ae6..abae4d1589 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -24,6 +24,7 @@ import org.groundplatform.feature.pdf.render.PdfItemSize import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset import org.groundplatform.feature.pdf.render.PdfRect + internal object TableLayout { /** * Layout of a single two-column table row. Left cell holds a single text block; right cell may From b4b1fa86f79978a09a874de7029da99bb7ba9492 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 15:07:32 +0200 Subject: [PATCH 29/50] improve test coverage --- .../components/qrcode/QrCodeGeneratorTest.kt | 53 ++++++++++++ .../pdf/AndroidPdfImageProviderTest.kt | 51 +++++++++++ .../feature/pdf/render/PdfGeometryTest.kt | 85 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt new file mode 100644 index 0000000000..edd4e8891f --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.qrcode + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QrCodeGeneratorTest { + + @Test + fun `fitsLogoCapacity is true for empty content`() { + assertTrue(fitsLogoCapacity("")) + } + + @Test + fun `fitsLogoCapacity is true for content below the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO - 1))) + } + + @Test + fun `fitsLogoCapacity is true for content exactly at the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO))) + } + + @Test + fun `fitsLogoCapacity is false for content above the byte limit`() { + assertFalse(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO + 1))) + } + + @Test + fun `fitsLogoCapacity counts UTF-8 bytes rather than characters`() { + // Each "€" encodes to 3 UTF-8 bytes, so this exceeds the limit despite the smaller char count. + val charCount = MAX_QR_BYTES_WITH_LOGO / 3 + 1 + val content = "€".repeat(charCount) + + assertTrue(content.length <= MAX_QR_BYTES_WITH_LOGO) + assertFalse(fitsLogoCapacity(content)) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt new file mode 100644 index 0000000000..c6da38dade --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import kotlin.test.assertEquals +import org.junit.Test + +class AndroidPdfImageProviderTest { + + @Test + fun `calculateInSampleSize returns 1 when the image is smaller than the target`() { + assertEquals(1, calculateInSampleSize(width = 100, height = 100, target = 200)) + } + + @Test + fun `calculateInSampleSize returns 1 when the image equals the target`() { + assertEquals(1, calculateInSampleSize(width = 200, height = 200, target = 200)) + } + + @Test + fun `calculateInSampleSize halves once when both dimensions are at least double the target`() { + // 400/2 = 200 >= 200, but 400/4 = 100 < 200, so the largest valid power of two is 2. + assertEquals(2, calculateInSampleSize(width = 400, height = 400, target = 200)) + } + + @Test + fun `calculateInSampleSize returns the largest power of two that keeps both axes above target`() { + // 800/4 = 200 >= 200, 800/8 = 100 < 200, so the result is 4. + assertEquals(4, calculateInSampleSize(width = 800, height = 800, target = 200)) + } + + @Test + fun `calculateInSampleSize is limited by the smaller dimension`() { + // Width could be sampled further, but height (300) only tolerates a sample size of 1 + // because 300/2 = 150 < 200. + assertEquals(1, calculateInSampleSize(width = 1600, height = 300, target = 200)) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt new file mode 100644 index 0000000000..aecfff52c0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals + +class PdfGeometryTest { + + @Test + fun `fitInside scales down to fit the width constraint`() { + val result = fitInside(width = 200, height = 100, maxWidth = 100, maxHeight = 100) + + assertEquals(100f, result.width) + assertEquals(50f, result.height) + } + + @Test + fun `fitInside scales down to fit the height constraint`() { + val result = fitInside(width = 100, height = 200, maxWidth = 100, maxHeight = 100) + + assertEquals(50f, result.width) + assertEquals(100f, result.height) + } + + @Test + fun `fitInside preserves aspect ratio`() { + val result = fitInside(width = 400, height = 300, maxWidth = 200, maxHeight = 200) + + assertEquals(200f, result.width) + assertEquals(150f, result.height) + assertEquals(result.width / result.height, 400f / 300f) + } + + @Test + fun `fitInside never upscales when the item already fits`() { + val result = fitInside(width = 50, height = 30, maxWidth = 100, maxHeight = 100) + + assertEquals(50f, result.width) + assertEquals(30f, result.height) + } + + @Test + fun `fitInside returns the same size when dimensions equal the bounds`() { + val result = fitInside(width = 100, height = 100, maxWidth = 100, maxHeight = 100) + + assertEquals(100f, result.width) + assertEquals(100f, result.height) + } + + @Test + fun `pointsToRenderPixels converts points to pixels at the configured DPI`() { + // 72 points = 1 inch, which at IMAGE_RENDER_DPI yields exactly that many pixels. + assertEquals(PdfConfig.IMAGE_RENDER_DPI.roundToInt(), pointsToRenderPixels(72f)) + } + + @Test + fun `pointsToRenderPixels scales linearly and rounds to the nearest pixel`() { + assertEquals(0, pointsToRenderPixels(0f)) + assertEquals((36f / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt(), pointsToRenderPixels(36f)) + assertEquals((10f / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt(), pointsToRenderPixels(10f)) + } + + @Test + fun `PdfRect exposes right and bottom derived from origin and size`() { + val rect = PdfRect(x = 10f, y = 20f, width = 30f, height = 40f) + + assertEquals(40f, rect.right) + assertEquals(60f, rect.bottom) + } +} From 3772ba2be710f9ff12f6bd71e5ef30280171e208 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 16:27:55 +0200 Subject: [PATCH 30/50] add tests for PdfWriter pagination --- .../pdf/render/PdfWriterPaginationTest.kt | 83 +++++++++++++++++++ .../feature/pdf/AndroidPdfRenderer.kt | 13 ++- .../feature/pdf/render/PdfWriter.kt | 15 +++- 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt new file mode 100644 index 0000000000..2913908bcf --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterPaginationTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, totalPages).apply { drawDocument(TEST_PDF_DOCUMENT) }.pageCount + + private fun newPdfWriter(document: SubmissionPdfDocument, totalPages: Int?): PdfWriter = + PdfWriter( + pdfCanvas = MeasurementPdfCanvas, + images = PdfImageSet(emptyMap()), + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private companion object { + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ), + qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), + footer = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ), + table = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + }, + ), + ) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index ef5670dfc5..0881714f32 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -40,7 +40,8 @@ class AndroidPdfRenderer : PdfRenderer { val totalPages = measurePageCount(document, images) val pdf = PdfDocument() try { - writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages).draw(document) + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) + .drawDocument(document) File(outputPath).outputStream().use { pdf.writeTo(it) } } finally { pdf.close() @@ -48,7 +49,9 @@ class AndroidPdfRenderer : PdfRenderer { } private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = - writer(document, images, MeasurementPdfCanvas, totalPages = null).draw(document).pageCount + writer(document, images, MeasurementPdfCanvas, totalPages = null) + .apply { drawDocument(document) } + .pageCount private fun writer( document: SubmissionPdfDocument, @@ -63,10 +66,4 @@ class AndroidPdfRenderer : PdfRenderer { footer = document.footer, totalPages = totalPages, ) - - private fun PdfWriter.draw(document: SubmissionPdfDocument): PdfWriter = apply { - drawQrBlock(document.qrBlock) - drawTable(document.table) - finalizePage() - } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index ebe78bfb38..eb5655d4f2 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -76,7 +76,13 @@ internal class PdfWriter( pdfCanvas.finishPage() } - fun drawQrBlock(block: QrBlock) { + fun drawDocument(document: SubmissionPdfDocument) { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } + + private fun drawQrBlock(block: QrBlock) { val qr = images[PdfImageSet.ImageRef.Qr] ?: return pageController.ensurePage() val captionLayout = @@ -88,7 +94,7 @@ internal class PdfWriter( cursor.moveTo(layout.nextCursorY) } - fun drawTable(table: SubmissionPdfDocument.Table) { + private fun drawTable(table: SubmissionPdfDocument.Table) { val rows = table.rows.takeIf { it.isNotEmpty() } ?: return pageController.ensurePage() val label = @@ -101,7 +107,8 @@ internal class PdfWriter( ) } val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) - val tableLabel = TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + val tableLabel = + TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) cursor.moveTo(tableLabel.nextCursorY) rows.forEach { row -> @@ -122,7 +129,7 @@ internal class PdfWriter( } } - fun finalizePage() { + private fun finalizePage() { pageController.finalizePage() } From 0eb9444cb31b483ad7283347b5d6313bb9025ea1 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 17:06:47 +0200 Subject: [PATCH 31/50] add logging for AndroidPdfImageProvider --- feature/pdf/build.gradle.kts | 1 + .../groundplatform/feature/pdf/AndroidPdfImageProvider.kt | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index a3338a1368..26855f6373 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -65,6 +65,7 @@ kotlin { implementation(libs.androidx.core.ktx) implementation(libs.androidx.exifinterface) implementation(libs.compose.ui) + implementation(libs.timber) } } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 5472474fb9..7bdf047f78 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -33,6 +33,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.groundplatform.feature.pdf.render.pointsToRenderPixels import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION import org.groundplatform.ui.components.qrcode.generateQrBitmap +import timber.log.Timber /** * Android implementation of [PdfImageProvider]. @@ -86,6 +87,7 @@ class AndroidPdfImageProvider( .asAndroidBitmap() .downscaledTo(qrMaxPx, qrMaxPx) } + .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } .getOrNull() private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { @@ -94,7 +96,10 @@ class AndroidPdfImageProvider( val file = File(rootDir, filename) return if (file.exists()) { runCatching { decodeSubsampled(file.absolutePath) } - .getOrNull() + .getOrElse { + Timber.e(it, "Failed to decode subsampled photo for PDF report") + null + } ?.let { decodedBitmap -> val oriented = applyExifOrientation(file, decodedBitmap) oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) From d7bd17c8da12a7a85ba8d607bd6524ff031951f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 17:21:41 +0200 Subject: [PATCH 32/50] add todo comment about iOS support for the implementations --- .../org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt | 2 ++ .../org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt | 2 ++ .../kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt | 2 ++ .../org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt | 2 ++ 4 files changed, 8 insertions(+) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 7bdf047f78..2ae401b748 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -44,6 +44,8 @@ import timber.log.Timber * @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. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfImageProvider( private val context: Context, @DrawableRes private val logoDrawableRes: Int, diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt index 1ae8029940..1d2e9e7f1a 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -20,6 +20,8 @@ import java.io.File private const val REPORTS_SUBDIR = "reports" +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { private val reportsDir diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index 0881714f32..11b4c6bf2c 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -29,6 +29,8 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just * counts pages. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfRenderer : PdfRenderer { override suspend fun render( diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt index 80f294da51..60dcd93559 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -21,6 +21,8 @@ import androidx.core.content.FileProvider import java.io.File /** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfReportLauncher( private val context: Context, private val fileProviderAuthority: String, From 4762a2ad84542b12ea3ccf7bac93416e78cacf20 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 18:40:36 +0200 Subject: [PATCH 33/50] simplify AndroidPdfImageProvider --- .../pdf/AndroidPdfImageProviderTest.kt | 79 ++++++++++--- .../feature/pdf/AndroidPdfImageProvider.kt | 111 ++++++++---------- .../feature/pdf/AndroidPdfOutputProvider.kt | 2 +- .../feature/pdf/AndroidPdfRenderer.kt | 2 +- .../feature/pdf/AndroidPdfReportLauncher.kt | 2 +- 5 files changed, 120 insertions(+), 76 deletions(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt index c6da38dade..af4a8d0175 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -15,37 +15,88 @@ */ package org.groundplatform.feature.pdf +import android.graphics.Bitmap import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class AndroidPdfImageProviderTest { @Test - fun `calculateInSampleSize returns 1 when the image is smaller than the target`() { - assertEquals(1, calculateInSampleSize(width = 100, height = 100, target = 200)) + fun `calculateInSampleSize does not subsample when image already fits within 2x`() { + assertEquals(1, calculateInSampleSize(width = 100, height = 100, maxWidth = 60, maxHeight = 60)) } @Test - fun `calculateInSampleSize returns 1 when the image equals the target`() { - assertEquals(1, calculateInSampleSize(width = 200, height = 200, target = 200)) + fun `calculateInSampleSize halves a square image down towards the box`() { + assertEquals(2, calculateInSampleSize(width = 100, height = 100, maxWidth = 50, maxHeight = 50)) } @Test - fun `calculateInSampleSize halves once when both dimensions are at least double the target`() { - // 400/2 = 200 >= 200, but 400/4 = 100 < 200, so the largest valid power of two is 2. - assertEquals(2, calculateInSampleSize(width = 400, height = 400, target = 200)) + fun `calculateInSampleSize subsamples a typical landscape photo`() { + assertEquals( + 2, + calculateInSampleSize(width = 4000, height = 3000, maxWidth = 1346, maxHeight = 1108), + ) } @Test - fun `calculateInSampleSize returns the largest power of two that keeps both axes above target`() { - // 800/4 = 200 >= 200, 800/8 = 100 < 200, so the result is 4. - assertEquals(4, calculateInSampleSize(width = 800, height = 800, target = 200)) + fun `calculateInSampleSize subsamples a tall image on its binding axis`() { + assertEquals( + 4, + calculateInSampleSize(width = 1000, height = 5000, maxWidth = 1346, maxHeight = 1108), + ) } @Test - fun `calculateInSampleSize is limited by the smaller dimension`() { - // Width could be sampled further, but height (300) only tolerates a sample size of 1 - // because 300/2 = 150 < 200. - assertEquals(1, calculateInSampleSize(width = 1600, height = 300, target = 200)) + fun `calculateInSampleSize never upsamples a tiny image`() { + assertEquals(1, calculateInSampleSize(width = 10, height = 10, maxWidth = 50, maxHeight = 50)) } + + @Test + fun `calculateInSampleSize leaves less than a 2x downscale for any input`() { + val maxWidth = 1346 + val maxHeight = 1108 + val dimensions = listOf(5000 to 1000, 1000 to 5000, 4000 to 3000, 3000 to 4000, 8000 to 8000) + for ((width, height) in dimensions) { + val sampleSize = calculateInSampleSize(width, height, maxWidth, maxHeight) + val decodedWidth = width / sampleSize + val decodedHeight = height / sampleSize + val fitScale = minOf(maxWidth.toFloat() / decodedWidth, maxHeight.toFloat() / decodedHeight) + assertTrue(fitScale > 0.5f) + } + } + + @Test + fun `scaledToFit returns the same bitmap when it already fits`() { + val bitmap = bitmap(10, 10) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit returns the same bitmap when it exactly matches the box`() { + val bitmap = bitmap(50, 50) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit downscales preserving aspect ratio`() { + val result = bitmap(100, 50).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(50, result.width) + assertEquals(25, result.height) + } + + @Test + fun `scaledToFit fits to the binding height when the box is wide`() { + val result = bitmap(50, 100).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(25, result.width) + assertEquals(50, result.height) + } + + private fun bitmap(width: Int, height: Int): Bitmap = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 2ae401b748..e5052d9277 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -28,6 +28,7 @@ import androidx.exifinterface.media.ExifInterface import java.io.File import kotlin.math.roundToInt import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.fitInside import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.groundplatform.feature.pdf.render.pointsToRenderPixels @@ -44,7 +45,7 @@ import timber.log.Timber * @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. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfImageProvider( private val context: Context, @@ -87,88 +88,80 @@ class AndroidPdfImageProvider( logoSizeFraction = PDF_LOGO_SIZE_FRACTION, ) .asAndroidBitmap() - .downscaledTo(qrMaxPx, qrMaxPx) + .scaledToFit(qrMaxPx, qrMaxPx) } .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } .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) - return if (file.exists()) { - runCatching { decodeSubsampled(file.absolutePath) } - .getOrElse { - Timber.e(it, "Failed to decode subsampled photo for PDF report") - null - } - ?.let { decodedBitmap -> - val oriented = applyExifOrientation(file, decodedBitmap) - oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) - } - } else null + val file = File(rootDir, remoteFilename.substringAfterLast('/')) + if (!file.exists()) return null + return runCatching { decodeScaledAndOriented(file) } + .onFailure { Timber.e(it, "Failed to decode photo for PDF report") } + .getOrNull() + } + + /** + * Decodes the photo subsampled to roughly the largest size it can occupy in the PDF, scales it to + * fit the photo box, then applies the EXIF orientation. + */ + private fun decodeScaledAndOriented(file: File): Bitmap? { + val path = file.absolutePath + val degrees = runCatching { ExifInterface(path).rotationDegrees }.getOrDefault(0) + // A 90°/270° EXIF rotation swaps width and height, so size the decode box accordingly. + val swapAxes = degrees == 90 || degrees == 270 + val boxWidth = if (swapAxes) photoMaxHeightPx else photoMaxWidthPx + val boxHeight = if (swapAxes) photoMaxWidthPx else photoMaxHeightPx + + val decoded = decodeSubsampled(path, boxWidth, boxHeight) ?: return null + return decoded.scaledToFit(boxWidth, boxHeight).rotated(degrees) } - /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ - private fun decodeSubsampled(path: String): Bitmap? { + /** Decodes the photo subsampled to roughly the [maxWidth] × [maxHeight] box it will occupy. */ + private fun decodeSubsampled(path: String, maxWidth: Int, maxHeight: Int): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, bounds) - // Orientation isn't known yet, so size against the larger target on both axes to be safe. - val target = maxOf(photoMaxWidthPx, photoMaxHeightPx) val options = BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, target) + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, maxWidth, maxHeight) } return BitmapFactory.decodeFile(path, options) } - - /** - * 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 - } } -/** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ -internal fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { +/** + * Largest power-of-two sample size that keeps the decoded bitmap at or above the [maxWidth] × + * [maxHeight]. + */ +internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { var sampleSize = 1 - while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + while (width / (sampleSize * 2) >= maxWidth || height / (sampleSize * 2) >= maxHeight) { sampleSize *= 2 } return sampleSize } /** - * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and - * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. + * Returns the receiver scaled down to fit [maxWidth] × [maxHeight], preserving aspect ratio and + * never upscaling. */ -private fun Bitmap.downscaledTo(maxWidthPx: Int, maxHeightPx: Int): Bitmap { - if (width <= maxWidthPx && height <= maxHeightPx) return this - val ratio = minOf(maxWidthPx.toFloat() / width, maxHeightPx.toFloat() / height) - val targetWidth = (width * ratio).roundToInt().coerceAtLeast(1) - val targetHeight = (height * ratio).roundToInt().coerceAtLeast(1) +internal fun Bitmap.scaledToFit(maxWidth: Int, maxHeight: Int): Bitmap { + val fitted = fitInside(width, height, maxWidth, maxHeight) + val targetWidth = fitted.width.roundToInt().coerceAtLeast(1) + val targetHeight = fitted.height.roundToInt().coerceAtLeast(1) + if (targetWidth == width && targetHeight == height) return this + return scale(targetWidth, targetHeight).also { if (it !== this) recycle() } +} - var current = this - var w = width - var h = height - while (w / 2 >= targetWidth && h / 2 >= targetHeight) { - w /= 2 - h /= 2 - val halved = current.scale(w, h) - if (current !== this) current.recycle() - current = halved - } - val result = current.scale(targetWidth, targetHeight) - if (current !== this) current.recycle() - return result +/** Returns the receiver rotated [degrees] clockwise, or unchanged when no rotation is needed. */ +private fun Bitmap.rotated(degrees: Int): Bitmap { + if (degrees == 0) return this + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } + .getOrElse { + Timber.w(it, "Failed to rotate photo by $degrees°, returning unrotated bitmap") + null + } + ?.also { if (it !== this) recycle() } ?: this } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt index 1d2e9e7f1a..0076867b7d 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -20,7 +20,7 @@ import java.io.File private const val REPORTS_SUBDIR = "reports" -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index 11b4c6bf2c..45712dfff5 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -29,7 +29,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just * counts pages. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfRenderer : PdfRenderer { diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt index 60dcd93559..d03dfba043 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -21,7 +21,7 @@ import androidx.core.content.FileProvider import java.io.File /** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfReportLauncher( private val context: Context, From 06e0b4e8b267895db474377e6c8c73b27db060e4 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 15:12:28 +0200 Subject: [PATCH 34/50] add unit tests for PdfWriter; AndroidPdfOutputProvider; PdfImageSet and PdfCanvas --- .../pdf/AndroidPdfOutputProviderTest.kt | 111 +++++++ .../feature/pdf/render/FakePdfCanvas.kt | 45 +++ .../feature/pdf/render/PdfCanvasTest.kt | 41 +++ .../pdf/render/PdfWriterPaginationTest.kt | 83 ------ .../feature/pdf/render/PdfWriterTest.kt | 281 ++++++++++++++++++ .../pdf/render/image/PdfImageSetTest.kt | 49 +++ 6 files changed, 527 insertions(+), 83 deletions(-) create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt delete mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt new file mode 100644 index 0000000000..f933b11cbc --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import java.io.File +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidPdfOutputProviderTest { + + private lateinit var context: Context + private lateinit var reportsDir: File + private lateinit var provider: AndroidPdfOutputProvider + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + reportsDir = File(context.cacheDir, "reports") + reportsDir.deleteRecursively() + provider = AndroidPdfOutputProvider(context) + } + + @Test + fun `newFilePath creates the reports directory and returns a pdf path`() { + val path = provider.newFilePath("report") + + assertTrue(reportsDir.isDirectory) + assertEquals(File(reportsDir, "report.pdf").absolutePath, path) + } + + @Test + fun `exists reflects whether the report file is present`() { + assertFalse(provider.exists("report")) + + File(provider.newFilePath("report")).writeText("pdf") + + assertTrue(provider.exists("report")) + } + + @Test + fun `listFiles returns an empty list when there is no reports directory`() { + assertTrue(provider.listFiles().isEmpty()) + } + + @Test + fun `listFiles returns only pdf files`() { + File(provider.newFilePath("a")).writeText("pdf") + File(provider.newFilePath("b")).writeText("pdf") + File(reportsDir, "notes.txt").writeText("ignore me") + + val names = provider.listFiles().map { File(it.path).name }.sorted() + + assertContentEquals(listOf("a.pdf", "b.pdf"), names) + } + + @Test + fun `listFiles returns the cached pdf files with the correct lastModified value`() { + val file = File(provider.newFilePath("report")).apply { writeText("pdf") } + file.setLastModified(987654321L) + + val entry = provider.listFiles().single() + + assertEquals(file.absolutePath, entry.path) + assertEquals(987654321L, entry.lastModifiedMillis) + } + + @Test + fun `deleteReport removes the file at the given path`() { + val path = provider.newFilePath("report") + File(path).writeText("pdf") + + provider.deleteReport(path) + + assertFalse(File(path).exists()) + } + + @Test + fun `pruneOldFiles deletes only reports older than a week`() { + val now = System.currentTimeMillis() + val fresh = File(provider.newFilePath("fresh")).apply { writeText("pdf") } + val stale = File(provider.newFilePath("stale")).apply { writeText("pdf") } + stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) + + provider.pruneOldFiles() + + assertTrue(fresh.exists()) + assertFalse(stale.exists()) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt new file mode 100644 index 0000000000..fa15e39464 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +internal class FakePdfCanvas : PdfCanvas { + val startedPages = mutableListOf() + var finishedPages = 0 + val drawnText = mutableListOf() + val drawnImages = mutableListOf() + + override fun startPage(pageNumber: Int) { + startedPages += pageNumber + } + + override fun finishPage() { + finishedPages++ + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + drawnText += layout.text.toString() + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + drawnImages += image + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt new file mode 100644 index 0000000000..2f0adcecc7 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import android.graphics.RectF +import android.text.StaticLayout +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfCanvasTest { + @Test + fun `MeasurementPdfCanvas ignores every call`() { + val layout = StaticLayout.Builder.obtain("body", 0, "body".length, TextPaint(), 100).build() + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + with(MeasurementPdfCanvas) { + startPage(pageNumber = 1) + drawStaticLayout(layout, x = 0f, y = 0f) + drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) + drawLine(0f, 0f, 10f, 10f) + finishPage() + } + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt deleted file mode 100644 index 2913908bcf..0000000000 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.feature.pdf.render - -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import org.groundplatform.feature.pdf.model.SubmissionPdfDocument -import org.groundplatform.feature.pdf.render.image.PdfImageSet -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class PdfWriterPaginationTest { - - @Test - fun `measurement and draw passes emit the same page count`() { - val measuredPages = renderPageCount(totalPages = null) - - val drawnPages = renderPageCount(totalPages = measuredPages) - - assertTrue(measuredPages > 1) - assertEquals(measuredPages, drawnPages) - } - - private fun renderPageCount(totalPages: Int?): Int = - newPdfWriter(TEST_PDF_DOCUMENT, totalPages).apply { drawDocument(TEST_PDF_DOCUMENT) }.pageCount - - private fun newPdfWriter(document: SubmissionPdfDocument, totalPages: Int?): PdfWriter = - PdfWriter( - pdfCanvas = MeasurementPdfCanvas, - images = PdfImageSet(emptyMap()), - totalPages = totalPages, - header = document.header, - footer = document.footer, - ) - - private companion object { - val TEST_PDF_DOCUMENT = - SubmissionPdfDocument( - header = - SubmissionPdfDocument.Header( - surveyLabel = "Survey", - surveyName = "Survey name", - jobLabel = "Job", - jobName = "Job name", - timestamp = "timestamp", - ), - qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), - footer = - SubmissionPdfDocument.Footer( - dataCollectorLabel = "Collector", - dataCollectorName = "John Doe", - userEmail = "user@gmail.com", - ), - table = - SubmissionPdfDocument.Table( - submissionLabel = "Submission", - loiName = "Plot 42", - rows = - List(200) { index -> - SubmissionPdfDocument.Row( - question = "Question $index", - answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), - ) - }, - ), - ) - } -} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt new file mode 100644 index 0000000000..d148f05520 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -0,0 +1,281 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + @Test + fun `does not open a page for a document with no qr and no rows`() { + val canvas = FakePdfCanvas() + + newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) + + assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.finishedPages) + } + + @Test + fun `opens and closes exactly one page for a single-page document`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals(listOf(1), canvas.startedPages) + assertEquals(1, canvas.finishedPages) + } + + @Test + fun `draws the header values on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(HEADER.surveyName)) + assertTrue(canvas.drawnText.contains(HEADER.jobName)) + assertTrue(canvas.drawnText.contains(HEADER.timestamp)) + } + + @Test + fun `draws the footer text on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue( + canvas.drawnText.contains( + "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + ) + ) + } + + @Test + fun `draws the header and footer on every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertEquals( + pdfWriter.pageCount, + canvas.drawnText.count { + it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + }, + ) + } + + @Test + fun `draws the qr image and caption when a qr image is provided`() { + val qr = pdfImage() + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = qr)) + + assertTrue(canvas.drawnImages.any { it.bitmap === qr.bitmap }) + assertTrue(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `skips the qr block when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertFalse(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `draws text answers as text layouts`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[0].question)) + assertTrue( + canvas.drawnText.contains( + (SINGLE_PAGE_DOCUMENT.table.rows[0].answer as SubmissionPdfDocument.Answer.Text) + .lines + .first() + ) + ) + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[1].question)) + } + + @Test + fun `draws photo answers as images`() { + val photo = pdfImage() + val canvas = + renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = mapOf("photo.jpg" to photo))) + + assertTrue(canvas.drawnImages.any { it.bitmap === photo.bitmap }) + } + + @Test + fun `does not draw a photo answer when its image is missing`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = emptyMap())) + + assertTrue(canvas.drawnImages.isEmpty()) + } + + @Test + fun `includes the page number in the footer when totalPages is set`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) + + assertTrue(canvas.drawnText.contains("1/1")) + } + + @Test + fun `omits the page number from the footer when totalPages is null`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = null) + + assertFalse(canvas.drawnText.any { it.contains("/") }) + } + + @Test + fun `skips the table when there are no rows`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(1), canvas.startedPages) + assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) + } + + private fun renderDocument( + document: SubmissionPdfDocument, + images: PdfImageSet = pdfImageSet(qr = pdfImage()), + totalPages: Int? = 1, + ): FakePdfCanvas = + FakePdfCanvas().also { newPdfWriter(document, images, it, totalPages).drawDocument(document) } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) + .apply { drawDocument(TEST_PDF_DOCUMENT) } + .pageCount + + private fun newPdfWriter( + document: SubmissionPdfDocument, + images: PdfImageSet, + canvas: PdfCanvas, + totalPages: Int? = null, + ): PdfWriter = + PdfWriter( + pdfCanvas = canvas, + images = images, + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + + private fun pdfImageSet( + qr: PdfImage? = null, + photos: Map = emptyMap(), + ): PdfImageSet = + PdfImageSet( + buildMap { + qr?.let { put(ImageRef.Qr, it) } + photos.forEach { (name, image) -> put(ImageRef.Photo(name), image) } + } + ) + + private companion object { + val HEADER = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ) + val FOOTER = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ) + + val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + + val TABLE = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = emptyList(), + ) + + val EMPTY_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = TABLE, + ) + + val SINGLE_PAGE_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + listOf( + SubmissionPdfDocument.Row( + question = "What is your name?", + answer = SubmissionPdfDocument.Answer.Text(listOf("John")), + ), + SubmissionPdfDocument.Row( + question = "Take a picture of a tree", + answer = SubmissionPdfDocument.Answer.Photo(remoteFilename = "photo.jpg"), + ), + ) + ), + ) + + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + } + ), + ) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt new file mode 100644 index 0000000000..78415a5adc --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.image + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef + +class PdfImageSetTest { + + @Test + fun `get returns null for a ref that is not in the set`() { + val set = PdfImageSet(emptyMap()) + + assertNull(set[ImageRef.Qr]) + assertNull(set[ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release invokes the onRelease callback`() { + var released = 0 + val set = PdfImageSet(emptyMap(), onRelease = { released++ }) + + set.release() + + assertEquals(1, released) + } + + @Test + fun `Photo refs are equal when their filenames match`() { + assertEquals(ImageRef.Photo("a.jpg"), ImageRef.Photo("a.jpg")) + assertTrue(ImageRef.Photo("a.jpg") != ImageRef.Photo("b.jpg")) + } +} From 86bab5b7ee71886aa1d44eee7e40061d404ef08f Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 15:12:28 +0200 Subject: [PATCH 35/50] add unit tests for PdfWriter; AndroidPdfOutputProvider; PdfImageSet and PdfCanvas --- .../pdf/AndroidPdfOutputProviderTest.kt | 111 +++++++ .../feature/pdf/render/FakePdfCanvas.kt | 45 +++ .../feature/pdf/render/PdfCanvasTest.kt | 41 +++ .../pdf/render/PdfWriterPaginationTest.kt | 83 ------ .../feature/pdf/render/PdfWriterTest.kt | 281 ++++++++++++++++++ .../pdf/render/image/PdfImageSetTest.kt | 49 +++ 6 files changed, 527 insertions(+), 83 deletions(-) create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt delete mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt new file mode 100644 index 0000000000..f933b11cbc --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import java.io.File +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidPdfOutputProviderTest { + + private lateinit var context: Context + private lateinit var reportsDir: File + private lateinit var provider: AndroidPdfOutputProvider + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + reportsDir = File(context.cacheDir, "reports") + reportsDir.deleteRecursively() + provider = AndroidPdfOutputProvider(context) + } + + @Test + fun `newFilePath creates the reports directory and returns a pdf path`() { + val path = provider.newFilePath("report") + + assertTrue(reportsDir.isDirectory) + assertEquals(File(reportsDir, "report.pdf").absolutePath, path) + } + + @Test + fun `exists reflects whether the report file is present`() { + assertFalse(provider.exists("report")) + + File(provider.newFilePath("report")).writeText("pdf") + + assertTrue(provider.exists("report")) + } + + @Test + fun `listFiles returns an empty list when there is no reports directory`() { + assertTrue(provider.listFiles().isEmpty()) + } + + @Test + fun `listFiles returns only pdf files`() { + File(provider.newFilePath("a")).writeText("pdf") + File(provider.newFilePath("b")).writeText("pdf") + File(reportsDir, "notes.txt").writeText("ignore me") + + val names = provider.listFiles().map { File(it.path).name }.sorted() + + assertContentEquals(listOf("a.pdf", "b.pdf"), names) + } + + @Test + fun `listFiles returns the cached pdf files with the correct lastModified value`() { + val file = File(provider.newFilePath("report")).apply { writeText("pdf") } + file.setLastModified(987654321L) + + val entry = provider.listFiles().single() + + assertEquals(file.absolutePath, entry.path) + assertEquals(987654321L, entry.lastModifiedMillis) + } + + @Test + fun `deleteReport removes the file at the given path`() { + val path = provider.newFilePath("report") + File(path).writeText("pdf") + + provider.deleteReport(path) + + assertFalse(File(path).exists()) + } + + @Test + fun `pruneOldFiles deletes only reports older than a week`() { + val now = System.currentTimeMillis() + val fresh = File(provider.newFilePath("fresh")).apply { writeText("pdf") } + val stale = File(provider.newFilePath("stale")).apply { writeText("pdf") } + stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) + + provider.pruneOldFiles() + + assertTrue(fresh.exists()) + assertFalse(stale.exists()) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt new file mode 100644 index 0000000000..fa15e39464 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +internal class FakePdfCanvas : PdfCanvas { + val startedPages = mutableListOf() + var finishedPages = 0 + val drawnText = mutableListOf() + val drawnImages = mutableListOf() + + override fun startPage(pageNumber: Int) { + startedPages += pageNumber + } + + override fun finishPage() { + finishedPages++ + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + drawnText += layout.text.toString() + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + drawnImages += image + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt new file mode 100644 index 0000000000..2f0adcecc7 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import android.graphics.RectF +import android.text.StaticLayout +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfCanvasTest { + @Test + fun `MeasurementPdfCanvas ignores every call`() { + val layout = StaticLayout.Builder.obtain("body", 0, "body".length, TextPaint(), 100).build() + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + with(MeasurementPdfCanvas) { + startPage(pageNumber = 1) + drawStaticLayout(layout, x = 0f, y = 0f) + drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) + drawLine(0f, 0f, 10f, 10f) + finishPage() + } + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt deleted file mode 100644 index 2913908bcf..0000000000 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.feature.pdf.render - -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import org.groundplatform.feature.pdf.model.SubmissionPdfDocument -import org.groundplatform.feature.pdf.render.image.PdfImageSet -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class PdfWriterPaginationTest { - - @Test - fun `measurement and draw passes emit the same page count`() { - val measuredPages = renderPageCount(totalPages = null) - - val drawnPages = renderPageCount(totalPages = measuredPages) - - assertTrue(measuredPages > 1) - assertEquals(measuredPages, drawnPages) - } - - private fun renderPageCount(totalPages: Int?): Int = - newPdfWriter(TEST_PDF_DOCUMENT, totalPages).apply { drawDocument(TEST_PDF_DOCUMENT) }.pageCount - - private fun newPdfWriter(document: SubmissionPdfDocument, totalPages: Int?): PdfWriter = - PdfWriter( - pdfCanvas = MeasurementPdfCanvas, - images = PdfImageSet(emptyMap()), - totalPages = totalPages, - header = document.header, - footer = document.footer, - ) - - private companion object { - val TEST_PDF_DOCUMENT = - SubmissionPdfDocument( - header = - SubmissionPdfDocument.Header( - surveyLabel = "Survey", - surveyName = "Survey name", - jobLabel = "Job", - jobName = "Job name", - timestamp = "timestamp", - ), - qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), - footer = - SubmissionPdfDocument.Footer( - dataCollectorLabel = "Collector", - dataCollectorName = "John Doe", - userEmail = "user@gmail.com", - ), - table = - SubmissionPdfDocument.Table( - submissionLabel = "Submission", - loiName = "Plot 42", - rows = - List(200) { index -> - SubmissionPdfDocument.Row( - question = "Question $index", - answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), - ) - }, - ), - ) - } -} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt new file mode 100644 index 0000000000..d148f05520 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -0,0 +1,281 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + @Test + fun `does not open a page for a document with no qr and no rows`() { + val canvas = FakePdfCanvas() + + newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) + + assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.finishedPages) + } + + @Test + fun `opens and closes exactly one page for a single-page document`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals(listOf(1), canvas.startedPages) + assertEquals(1, canvas.finishedPages) + } + + @Test + fun `draws the header values on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(HEADER.surveyName)) + assertTrue(canvas.drawnText.contains(HEADER.jobName)) + assertTrue(canvas.drawnText.contains(HEADER.timestamp)) + } + + @Test + fun `draws the footer text on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue( + canvas.drawnText.contains( + "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + ) + ) + } + + @Test + fun `draws the header and footer on every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertEquals( + pdfWriter.pageCount, + canvas.drawnText.count { + it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + }, + ) + } + + @Test + fun `draws the qr image and caption when a qr image is provided`() { + val qr = pdfImage() + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = qr)) + + assertTrue(canvas.drawnImages.any { it.bitmap === qr.bitmap }) + assertTrue(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `skips the qr block when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertFalse(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `draws text answers as text layouts`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[0].question)) + assertTrue( + canvas.drawnText.contains( + (SINGLE_PAGE_DOCUMENT.table.rows[0].answer as SubmissionPdfDocument.Answer.Text) + .lines + .first() + ) + ) + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[1].question)) + } + + @Test + fun `draws photo answers as images`() { + val photo = pdfImage() + val canvas = + renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = mapOf("photo.jpg" to photo))) + + assertTrue(canvas.drawnImages.any { it.bitmap === photo.bitmap }) + } + + @Test + fun `does not draw a photo answer when its image is missing`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = emptyMap())) + + assertTrue(canvas.drawnImages.isEmpty()) + } + + @Test + fun `includes the page number in the footer when totalPages is set`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) + + assertTrue(canvas.drawnText.contains("1/1")) + } + + @Test + fun `omits the page number from the footer when totalPages is null`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = null) + + assertFalse(canvas.drawnText.any { it.contains("/") }) + } + + @Test + fun `skips the table when there are no rows`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(1), canvas.startedPages) + assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) + } + + private fun renderDocument( + document: SubmissionPdfDocument, + images: PdfImageSet = pdfImageSet(qr = pdfImage()), + totalPages: Int? = 1, + ): FakePdfCanvas = + FakePdfCanvas().also { newPdfWriter(document, images, it, totalPages).drawDocument(document) } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) + .apply { drawDocument(TEST_PDF_DOCUMENT) } + .pageCount + + private fun newPdfWriter( + document: SubmissionPdfDocument, + images: PdfImageSet, + canvas: PdfCanvas, + totalPages: Int? = null, + ): PdfWriter = + PdfWriter( + pdfCanvas = canvas, + images = images, + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + + private fun pdfImageSet( + qr: PdfImage? = null, + photos: Map = emptyMap(), + ): PdfImageSet = + PdfImageSet( + buildMap { + qr?.let { put(ImageRef.Qr, it) } + photos.forEach { (name, image) -> put(ImageRef.Photo(name), image) } + } + ) + + private companion object { + val HEADER = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ) + val FOOTER = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ) + + val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + + val TABLE = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = emptyList(), + ) + + val EMPTY_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = TABLE, + ) + + val SINGLE_PAGE_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + listOf( + SubmissionPdfDocument.Row( + question = "What is your name?", + answer = SubmissionPdfDocument.Answer.Text(listOf("John")), + ), + SubmissionPdfDocument.Row( + question = "Take a picture of a tree", + answer = SubmissionPdfDocument.Answer.Photo(remoteFilename = "photo.jpg"), + ), + ) + ), + ) + + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + } + ), + ) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt new file mode 100644 index 0000000000..78415a5adc --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.image + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef + +class PdfImageSetTest { + + @Test + fun `get returns null for a ref that is not in the set`() { + val set = PdfImageSet(emptyMap()) + + assertNull(set[ImageRef.Qr]) + assertNull(set[ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release invokes the onRelease callback`() { + var released = 0 + val set = PdfImageSet(emptyMap(), onRelease = { released++ }) + + set.release() + + assertEquals(1, released) + } + + @Test + fun `Photo refs are equal when their filenames match`() { + assertEquals(ImageRef.Photo("a.jpg"), ImageRef.Photo("a.jpg")) + assertTrue(ImageRef.Photo("a.jpg") != ImageRef.Photo("b.jpg")) + } +} From 8d5137b9d4c4a1b91fbce6f3a5da05a63acce4f8 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 15:38:53 +0200 Subject: [PATCH 36/50] fix code style check --- .../pdf/AndroidPdfOutputProviderTest.kt | 30 +++++++++++-------- .../feature/pdf/render/FakePdfCanvas.kt | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt index f933b11cbc..7023daae7a 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -37,14 +37,14 @@ class AndroidPdfOutputProviderTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() - reportsDir = File(context.cacheDir, "reports") + reportsDir = File(context.cacheDir, PDF_SUBDIR) reportsDir.deleteRecursively() provider = AndroidPdfOutputProvider(context) } @Test fun `newFilePath creates the reports directory and returns a pdf path`() { - val path = provider.newFilePath("report") + val path = provider.newFilePath(PDF_FILE_NAME) assertTrue(reportsDir.isDirectory) assertEquals(File(reportsDir, "report.pdf").absolutePath, path) @@ -52,11 +52,11 @@ class AndroidPdfOutputProviderTest { @Test fun `exists reflects whether the report file is present`() { - assertFalse(provider.exists("report")) + assertFalse(provider.exists(PDF_FILE_NAME)) - File(provider.newFilePath("report")).writeText("pdf") + File(provider.newFilePath(PDF_FILE_NAME)).writeText(PDF_TEXT) - assertTrue(provider.exists("report")) + assertTrue(provider.exists(PDF_FILE_NAME)) } @Test @@ -66,8 +66,8 @@ class AndroidPdfOutputProviderTest { @Test fun `listFiles returns only pdf files`() { - File(provider.newFilePath("a")).writeText("pdf") - File(provider.newFilePath("b")).writeText("pdf") + File(provider.newFilePath("a")).writeText(PDF_TEXT) + File(provider.newFilePath("b")).writeText(PDF_TEXT) File(reportsDir, "notes.txt").writeText("ignore me") val names = provider.listFiles().map { File(it.path).name }.sorted() @@ -77,7 +77,7 @@ class AndroidPdfOutputProviderTest { @Test fun `listFiles returns the cached pdf files with the correct lastModified value`() { - val file = File(provider.newFilePath("report")).apply { writeText("pdf") } + val file = File(provider.newFilePath(PDF_SUBDIR)).apply { writeText(PDF_TEXT) } file.setLastModified(987654321L) val entry = provider.listFiles().single() @@ -88,8 +88,8 @@ class AndroidPdfOutputProviderTest { @Test fun `deleteReport removes the file at the given path`() { - val path = provider.newFilePath("report") - File(path).writeText("pdf") + val path = provider.newFilePath(PDF_SUBDIR) + File(path).writeText(PDF_TEXT) provider.deleteReport(path) @@ -99,8 +99,8 @@ class AndroidPdfOutputProviderTest { @Test fun `pruneOldFiles deletes only reports older than a week`() { val now = System.currentTimeMillis() - val fresh = File(provider.newFilePath("fresh")).apply { writeText("pdf") } - val stale = File(provider.newFilePath("stale")).apply { writeText("pdf") } + val fresh = File(provider.newFilePath("fresh")).apply { writeText(PDF_TEXT) } + val stale = File(provider.newFilePath("stale")).apply { writeText(PDF_TEXT) } stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) provider.pruneOldFiles() @@ -108,4 +108,10 @@ class AndroidPdfOutputProviderTest { assertTrue(fresh.exists()) assertFalse(stale.exists()) } + + private companion object { + const val PDF_TEXT = "This is a test PDF." + const val PDF_SUBDIR = "reports" + const val PDF_FILE_NAME = "report" + } } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt index fa15e39464..93db97243e 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -42,4 +42,4 @@ internal class FakePdfCanvas : PdfCanvas { } override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit -} \ No newline at end of file +} From b7d18a61119a4bb4486f9be2ee5610012d09b15f Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 16:45:05 +0200 Subject: [PATCH 37/50] improve AndroidPdfImageProviderTest; add tests for DocumentPdfCanvas and QrCodeGenerator --- .../qrcode/QrCodeGeneratorAndroidTest.kt | 70 +++++++++++++++++++ .../pdf/AndroidPdfImageProviderTest.kt | 48 +++++++++++++ .../pdf/render/DocumentPdfCanvasTest.kt | 57 +++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt diff --git a/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt new file mode 100644 index 0000000000..1b944757ea --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt @@ -0,0 +1,70 @@ +/* + * 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.qrcode + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlin.test.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +class QrCodeGeneratorAndroidTest { + + @Test + fun `encodeQrBitmap produces a square bitmap at the configured size`() { + val qr = encodeQrBitmap(QR_CONTENT, useHighEcc = false) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap without a logo returns a square code`() { + val qr = generateQrBitmap(content = QR_CONTENT, logo = null) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap composites a logo when the content fits the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + + val qr = generateQrBitmap(content = "short", logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap skips the logo when the content exceeds the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + val tooLong = "1".repeat(MAX_QR_BYTES_WITH_LOGO + 1) + + val qr = generateQrBitmap(content = tooLong, logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + private companion object { + const val QR_CONTENT = "https://google.com" + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt index af4a8d0175..fb7189f119 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -17,11 +17,17 @@ package org.groundplatform.feature.pdf import android.graphics.Bitmap import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class AndroidPdfImageProviderTest { @@ -97,6 +103,48 @@ class AndroidPdfImageProviderTest { assertEquals(50, result.height) } + @Test + fun `load generates a qr image when content is provided`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + + assertNotNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load returns no qr image when content is null`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = emptySet()) + + assertNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load skips empty photo filenames`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("")) + + assertNull(images[PdfImageSet.ImageRef.Photo("")]) + } + + @Test + fun `load skips photos whose file does not exist`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("missing.jpg")) + + assertNull(images[PdfImageSet.ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release recycles the bitmaps it loaded`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + val qrBitmap = images[PdfImageSet.ImageRef.Qr]!!.bitmap + assertFalse(qrBitmap.isRecycled) + + images.release() + + assertTrue(qrBitmap.isRecycled) + } + + private fun newProvider(): AndroidPdfImageProvider = + AndroidPdfImageProvider(RuntimeEnvironment.getApplication(), logoDrawableRes = 0) + private fun bitmap(width: Int, height: Int): Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt new file mode 100644 index 0000000000..a25fc8d176 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.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.feature.pdf.render + +import android.graphics.Bitmap +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import android.text.TextPaint +import kotlin.test.assertFailsWith +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DocumentPdfCanvasTest { + + private val canvas = DocumentPdfCanvas(PdfDocument()) + + @Test + fun `drawLine before a page is started fails`() { + assertFailsWith { canvas.drawLine(0f, 0f, 10f, 10f) } + } + + @Test + fun `drawImage before a page is started fails`() { + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + assertFailsWith { + canvas.drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = false) + } + } + + @Test + fun `drawStaticLayout before a page is started fails`() { + val layout = StaticLayout.Builder.obtain("body", 0, 4, TextPaint(), 100).build() + assertFailsWith { canvas.drawStaticLayout(layout, x = 0f, y = 0f) } + } + + @Test + fun `finishPage with no page open does nothing`() { + canvas.finishPage() + } +} From a7d119e14bb49b79309b34280a88c688549b7601 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 16:39:44 +0200 Subject: [PATCH 38/50] update AndroidPdfImageProvider#load to use async/awaitAll --- .../feature/pdf/AndroidPdfImageProvider.kt | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index f4c1ee573f..82049f1746 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -27,6 +27,9 @@ import androidx.core.graphics.scale import androidx.exifinterface.media.ExifInterface import java.io.File import kotlin.math.roundToInt +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import org.groundplatform.feature.pdf.render.fitInside import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet @@ -57,28 +60,38 @@ class AndroidPdfImageProvider( private val photoMaxWidthPx = pointsToRenderPixels(TableLayout.ANSWER_TEXT_WIDTH.toFloat()) private val photoMaxHeightPx = pointsToRenderPixels(TableLayout.PHOTO_MAX_HEIGHT.toFloat()) - 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) + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet = + coroutineScope { + val deferredQr = qrContent?.let { content -> + async { + generateQrCodeBitmap(content)?.let { bitmap -> + PdfImageSet.ImageRef.Qr to bitmap + } + } } - } - photoFilenames - .filter { it.isNotEmpty() } - .forEach { filename -> - loadPhotoBitmap(filename)?.let { bitmap -> - bitmapsToRelease += bitmap - images[PdfImageSet.ImageRef.Photo(filename)] = PdfImage(bitmap) - } + val deferredPhotos = + photoFilenames + .filter { it.isNotEmpty() } + .map { filename -> + async { + loadPhotoBitmap(filename)?.let { bitmap -> + PdfImageSet.ImageRef.Photo(filename) to bitmap + } + } + } + + val results = (listOfNotNull(deferredQr) + deferredPhotos).awaitAll().filterNotNull() + + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + results.forEach { (ref, bitmap) -> + bitmapsToRelease += bitmap + images[ref] = PdfImage(bitmap) } - return PdfImageSet(images) { bitmapsToRelease.forEach(Bitmap::recycle) } - } + PdfImageSet(images = images, onRelease = { bitmapsToRelease.forEach(Bitmap::recycle) }) + } private fun generateQrCodeBitmap(content: String): Bitmap? = runCatching { From 14fc959b53e7493239cf794b017ae062c637864a Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:01:15 +0200 Subject: [PATCH 39/50] simplify calculateInSampleSize --- .../feature/pdf/AndroidPdfImageProvider.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 82049f1746..5e41d143f8 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -149,8 +149,15 @@ class AndroidPdfImageProvider( * [maxHeight]. */ internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + // True if at least one dimension is still larger than the target when downsampled. + fun canDownsample(sampleSize: Int): Boolean { + val meetsTargetWidth = width / sampleSize >= maxWidth + val meetsTargetHeight = height / sampleSize >= maxHeight + return meetsTargetWidth || meetsTargetHeight + } + var sampleSize = 1 - while (width / (sampleSize * 2) >= maxWidth || height / (sampleSize * 2) >= maxHeight) { + while (canDownsample(sampleSize * 2)) { sampleSize *= 2 } return sampleSize From 2f7b3ba30e5563e3ed610e3deaccbbf3efef500c Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:02:18 +0200 Subject: [PATCH 40/50] apply suggestion to PdfWriter --- .../org/groundplatform/feature/pdf/render/PdfWriter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index 850fa3838d..d31dd0d5f8 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -196,8 +196,9 @@ internal class PdfWriter( private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { val questionLayout = staticLayout(questionText, paints.body, TableLayout.TASK_TEXT_WIDTH) val answerLayout = - if (answerText.isEmpty()) null - else staticLayout(answerText, paints.body, TableLayout.ANSWER_TEXT_WIDTH) + answerText + .takeIf { it.isNotEmpty() } + ?.let { staticLayout(it, paints.body, TableLayout.ANSWER_TEXT_WIDTH) } val photoSize = photo?.let { fitInside(it.width, it.height, TableLayout.ANSWER_TEXT_WIDTH, TableLayout.PHOTO_MAX_HEIGHT) } From d388ce7feb8117cdd4aaa3a5f3ed1ed5ecb682fa Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:46:12 +0200 Subject: [PATCH 41/50] add IO dispatcher to AndroidPdfRenderer file operation --- .../org/groundplatform/feature/pdf/AndroidPdfRenderer.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index 45712dfff5..19d0949e86 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -17,6 +17,8 @@ package org.groundplatform.feature.pdf import android.graphics.pdf.PdfDocument import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.feature.pdf.render.DocumentPdfCanvas import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas @@ -31,7 +33,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet */ // TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 -class AndroidPdfRenderer : PdfRenderer { +class AndroidPdfRenderer(private val ioDispatcher: CoroutineDispatcher) : PdfRenderer { override suspend fun render( document: SubmissionPdfDocument, @@ -44,7 +46,7 @@ class AndroidPdfRenderer : PdfRenderer { try { writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) .drawDocument(document) - File(outputPath).outputStream().use { pdf.writeTo(it) } + withContext(ioDispatcher) { File(outputPath).outputStream().use { pdf.writeTo(it) } } } finally { pdf.close() } From 8948a19b7648da4bed80600640006e7f6856a6b5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 18:04:26 +0200 Subject: [PATCH 42/50] update top border drawing logic for the table --- .../feature/pdf/render/PdfWriter.kt | 4 ++++ .../feature/pdf/render/PdfPageController.kt | 9 ++++++++ .../pdf/render/PdfPageControllerTest.kt | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index d31dd0d5f8..d23bfe5a12 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -212,7 +212,11 @@ internal class PdfWriter( leftTextHeight = questionHeight, rightTextHeight = answerHeight, rightImageSize = photoSize, + includeTopBorder = pageController.isFirstTableRowOnPage, ) + if (pageController.isFirstTableRowOnPage) { + pageController.consumeFirstTableRowOnPage() + } rowLayout.borders.drawableLines.forEach { drawLine(it) } drawStaticLayoutAt(questionLayout, rowLayout.content.leftTextOffset) diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt index b256481cb7..3224310098 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -34,6 +34,9 @@ internal class PdfPageController( private var pageIndex = 0 private var pageOpen = false + var isFirstTableRowOnPage = true + private set + /** Number of pages emitted so far. Equals the current page number while a page is open. */ val pageCount: Int get() = pageIndex @@ -42,6 +45,11 @@ internal class PdfPageController( if (!pageOpen) beginPage() } + /** Records that the first table row on the current page has been drawn. */ + fun consumeFirstTableRowOnPage() { + isFirstTableRowOnPage = false + } + fun newPageIfShort(spaceNeeded: Float) { ensurePage() if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return @@ -58,6 +66,7 @@ internal class PdfPageController( private fun beginPage() { pageIndex++ pageOpen = true + isFirstTableRowOnPage = true cursor.reset() lifecycle.onPageStarted(pageIndex) } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt index 4ad96927a6..bc2ebdc2a9 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -17,6 +17,7 @@ package org.groundplatform.feature.pdf.render import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class PdfPageControllerTest { @@ -168,6 +169,27 @@ class PdfPageControllerTest { assertEquals(2, controller.pageCount) } + @Test + fun `isFirstTableRowOnPage is true until consumed`() { + controller.ensurePage() + + assertTrue(controller.isFirstTableRowOnPage) + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + } + + @Test + fun `isFirstTableRowOnPage resets to true on a new page`() { + controller.ensurePage() + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertTrue(controller.isFirstTableRowOnPage) + } + private sealed interface PageEvent { data class Started(val pageNumber: Int) : PageEvent From 25ffb1f2be3c70261d2047b494c9103f3f41769d Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 18:24:34 +0200 Subject: [PATCH 43/50] add unit tests to assure PdfWriter only draws one internal border between rows --- .../feature/pdf/render/FakePdfCanvas.kt | 5 +++- .../feature/pdf/render/PdfWriterTest.kt | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt index 93db97243e..34060fb339 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -24,6 +24,7 @@ internal class FakePdfCanvas : PdfCanvas { var finishedPages = 0 val drawnText = mutableListOf() val drawnImages = mutableListOf() + val drawnLines = mutableListOf() override fun startPage(pageNumber: Int) { startedPages += pageNumber @@ -41,5 +42,7 @@ internal class FakePdfCanvas : PdfCanvas { drawnImages += image } - override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + drawnLines += PdfLine(x1, y1, x2, y2) + } } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt index d148f05520..172fb6858d 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -157,6 +157,28 @@ class PdfWriterTest { assertFalse(canvas.drawnText.any { it.contains("/") }) } + @Test + fun `draws a top border on only the first table row of a page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + // SINGLE_PAGE_DOCUMENT has 2 rows on one page: the first gets a top border, the second doesn't. + assertEquals(2, canvas.drawnLines.count { it.startX == it.endX }) + assertEquals(1, canvas.topBorderCount()) + } + + @Test + fun `draws a fresh top border on the first row of every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + // Every page resets the flag, so each page's first row draws exactly 1 top border. + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.topBorderCount()) + } + @Test fun `skips the table when there are no rows`() { val tableless = @@ -194,6 +216,13 @@ class PdfWriterTest { footer = document.footer, ) + private fun FakePdfCanvas.topBorderCount(): Int { + // This counts the rows as each row draws exactly 1 vertical divider + val rowCount = drawnLines.count { it.startX == it.endX } + val horizontalLines = drawnLines.count { it.startY == it.endY } + return horizontalLines - rowCount + } + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) private fun pdfImageSet( From 9523ba2b0f243dc088cb51a1ba9e000bf6413d0d Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 16 Jun 2026 18:07:15 +0200 Subject: [PATCH 44/50] update di setup with correct coroutines --- .../main/java/org/groundplatform/android/di/PdfModule.kt | 8 ++++++-- .../android/di/coroutines/CoroutineDispatchersModule.kt | 6 ++++++ .../android/TestCoroutineDispatchersModule.kt | 5 +++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt index f2f18bfde8..e3badfb4b3 100644 --- a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt @@ -25,6 +25,7 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher import org.groundplatform.android.BuildConfig import org.groundplatform.android.R +import org.groundplatform.android.di.coroutines.DefaultDispatcher import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.feature.pdf.AndroidPdfImageProvider import org.groundplatform.feature.pdf.AndroidPdfOutputProvider @@ -55,7 +56,10 @@ object PdfModule { fun providePdfOutputFactory(@ApplicationContext context: Context): PdfOutputProvider = AndroidPdfOutputProvider(context) - @Provides @Singleton fun providePdfRenderer(): PdfRenderer = AndroidPdfRenderer() + @Provides + @Singleton + fun providePdfRenderer(@IoDispatcher ioDispatcher: CoroutineDispatcher): PdfRenderer = + AndroidPdfRenderer(ioDispatcher) @Provides @Singleton @@ -89,7 +93,7 @@ object PdfModule { renderer: PdfRenderer, outputProvider: PdfOutputProvider, launcher: PdfReportLauncher, - @IoDispatcher coroutineDispatcher: CoroutineDispatcher, + @DefaultDispatcher coroutineDispatcher: CoroutineDispatcher, ): PdfExportService = PdfExportService( imageProvider = imageProvider, diff --git a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt index 12b9c9dfc9..8295ea7f01 100644 --- a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt @@ -30,8 +30,14 @@ object CoroutineDispatchersModule { @IoDispatcher @Provides fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO @MainDispatcher @Provides fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default } +@Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class DefaultDispatcher + @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class IoDispatcher @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class MainDispatcher diff --git a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt index 7ae38281f4..acaa42caa9 100644 --- a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt +++ b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import org.groundplatform.android.di.coroutines.CoroutineDispatchersModule +import org.groundplatform.android.di.coroutines.DefaultDispatcher import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.android.di.coroutines.MainDispatcher @@ -43,4 +44,8 @@ object TestCoroutineDispatchersModule { @MainDispatcher @Provides fun provideMainDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher } From 71653235212e236cba5e6d6dcfd4118726b3fd27 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 16 Jun 2026 18:08:08 +0200 Subject: [PATCH 45/50] fix incomplete tests --- .../home/mapcontainer/jobs/JobMapComponent.kt | 16 +++++++------- .../mapcontainer/jobs/ShareLocationModal.kt | 21 +++++++++--------- .../DataSubmissionConfirmationScreenTest.kt | 22 +++++++++++++++---- .../HomeScreenMapContainerScreenTest.kt | 3 ++- .../HomeScreenMapContainerViewModelTest.kt | 2 +- .../mapcontainer/jobs/JobMapComponentTest.kt | 20 ++++++++++++++--- .../jobs/ShareLocationModalTest.kt | 17 +++++++++++--- .../pdf/helpers/FakePdfExportService.kt | 1 + 8 files changed, 72 insertions(+), 30 deletions(-) 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 9c182c79be..e7ae41816b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt @@ -48,7 +48,7 @@ import org.groundplatform.ui.theme.AppTheme @Composable fun JobMapComponent( state: JobMapComponentState, - onAction: (JobMapComponentAction) -> Unit, + onJobComponentAction: (JobMapComponentAction) -> Unit, onLoiReportAction: (LoiReportAction) -> Unit, ) { when (state) { @@ -57,9 +57,9 @@ fun JobMapComponent( LoiJobSheet( state = state.loi, - onCollectClicked = { onAction(OnAddDataClicked(state.loi)) }, - onDeleteClicked = { onAction(OnDeleteSiteClicked(state.loi)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobCardDismissed) }, + onCollectClicked = { onJobComponentAction(OnAddDataClicked(state.loi)) }, + onDeleteClicked = { onJobComponentAction(OnDeleteSiteClicked(state.loi)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobCardDismissed) }, onShareClicked = { showShareLoiModal = true }, ) @@ -72,13 +72,13 @@ fun JobMapComponent( } } is JobMapComponentState.AddLoiButton -> { - AddLoiButton(onClick = { onAction(JobMapComponentAction.OnAddLoiButtonClicked) }) + AddLoiButton(onClick = { onJobComponentAction(JobMapComponentAction.OnAddLoiButtonClicked) }) } is JobMapComponentState.JobSelectionModal -> { JobSelectionModal( jobs = state.jobs.map { it.job }, - onJobClicked = { job -> onAction(OnJobSelected(job)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, + onJobClicked = { job -> onJobComponentAction(OnJobSelected(job)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, ) } is JobMapComponentState.Hidden -> {} @@ -146,7 +146,7 @@ private fun JobMapComponentPreview() { ) ) ), - onAction = {}, + onJobComponentAction = {}, onLoiReportAction = {}, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt index 4696679ed8..617a839276 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.window.DialogProperties import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date +import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -55,7 +56,6 @@ import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme -import kotlin.time.Clock import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @OptIn(ExperimentalMaterial3Api::class) @@ -108,17 +108,18 @@ fun ShareLocationModal( loiName = loiReport.loiName, userName = it.userName, date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, - onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, - )} + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) + } } - } - TextButton( - modifier = Modifier.align(Alignment.End).padding(top = 16.dp), - onClick = onDismiss, - ) { - Text(text = stringResource(R.string.close)) + TextButton( + modifier = Modifier.align(Alignment.End).padding(16.dp), + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.close)) + } } } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt index 3b558eea13..0157c0f99e 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 @@ -48,7 +48,11 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Shows the correct content on portrait`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } composeTestRule @@ -71,7 +75,11 @@ class DataSubmissionConfirmationScreenTest { LocalConfiguration provides Configuration().apply { orientation = Configuration.ORIENTATION_LANDSCAPE } ) { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } } @@ -90,7 +98,7 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Does not show QR section if the LoiReport is null`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}) + DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}, onLoiReportAction = {}) } composeTestRule @@ -107,6 +115,7 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismissed = {}, + onLoiReportAction = {}, ) } @@ -119,6 +128,7 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = null), onDismissed = {}, + onLoiReportAction = {}, ) } @@ -130,7 +140,11 @@ class DataSubmissionConfirmationScreenTest { var dismissed = false composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = { dismissed = true }) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt index 1c26ce5cc1..a14c36e579 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Test import kotlin.test.assertTrue import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.R @@ -34,6 +33,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -176,6 +176,7 @@ class HomeScreenMapContainerScreenTest { jobComponentState = jobComponentState, onBaseMapAction = onBaseMapAction, onJobComponentAction = onJobComponentAction, + onLoiReportAction = {}, ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index 6691549013..ee7054f6b4 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -99,7 +99,7 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { loi = LOCATION_OF_INTEREST, submissionCount = 0, showDeleteLoiButton = true, - loiReport = LOCATION_OF_INTEREST_LOI_REPORT, + loiReport = LOCATION_OF_INTEREST_LOI_REPORT.copy(submissionDetails = null), ) ) ) diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt index 39c014c2c4..0806fd6037 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt @@ -238,7 +238,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -258,7 +262,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -273,6 +281,12 @@ class JobMapComponentTest { state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit = {}, ) { - composeTestRule.setContent { JobMapComponent(state = state, onAction = { onAction(it) }) } + composeTestRule.setContent { + JobMapComponent( + state = state, + onJobComponentAction = { onAction(it) }, + onLoiReportAction = {}, + ) + } } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt index 8ffad04946..c126ae1b66 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 @@ -46,7 +46,9 @@ class ShareLocationModalTest { @Test fun `Modal is displayed correctly and shows the QR code with the LOI geometry`() { composeTestRule.setContent { - AppTheme { ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}) } + AppTheme { + ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}, onLoiReportAction = {}) + } } composeTestRule.onNodeWithText(getString(R.string.share_location)).assertIsDisplayed() composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed() @@ -65,6 +67,7 @@ class ShareLocationModalTest { ShareLocationModal( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismiss = {}, + onLoiReportAction = {}, ) } } @@ -76,7 +79,11 @@ class ShareLocationModalTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissionDetails = null), onDismiss = {}) + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = null), + onDismiss = {}, + onLoiReportAction = {}, + ) } } @@ -88,7 +95,11 @@ class ShareLocationModalTest { var dismissed = false composeTestRule.setContent { - ShareLocationModal(loiReport = LOI_REPORT, onDismiss = { dismissed = true }) + ShareLocationModal( + loiReport = LOI_REPORT, + onDismiss = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt index 0d957593c6..e8e7614116 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt @@ -24,6 +24,7 @@ import org.groundplatform.feature.pdf.PdfReportLauncher import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.feature.pdf.render.image.PdfImageSet +@Suppress("UseDataClass") class FakePdfExportService(val outputPath: String = "/tmp/report.pdf") { var renderError: Throwable? = null From 86a7f1eeb8f5c058d4d25acb7e82da21ec756599 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 16 Jun 2026 18:17:14 +0200 Subject: [PATCH 46/50] move LoiReportExporter call to DataCollectionViewModel --- .../datacollection/DataCollectionFragment.kt | 20 +------------------ .../ui/datacollection/DataCollectionScreen.kt | 5 +++-- .../datacollection/DataCollectionViewModel.kt | 14 +++++++++++++ .../DataCollectionScreenTest.kt | 1 + 4 files changed, 19 insertions(+), 21 deletions(-) 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 4794b4517a..182480ad14 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,11 +20,9 @@ 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 @@ -32,14 +30,11 @@ 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.feature.pdf.LoiReportExporter -import org.groundplatform.ui.components.loireport.LoiReportAction /** Fragment allowing the user to collect data to complete a task. */ @AndroidEntryPoint class DataCollectionFragment : AbstractFragment(), BackPressListener { @Inject lateinit var popups: EphemeralPopups - @Inject lateinit var loiReportExporter: LoiReportExporter val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection) @@ -60,7 +55,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, - onLoiReportAction = { handleLoiReportAction(it) }, + onReportExportError = { popups.ErrorPopup().unknownError() }, ) } @@ -97,19 +92,6 @@ 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 - } - - lifecycleScope.launch { - loiReportExporter.export(loiReport, action).onFailure { popups.ErrorPopup().unknownError() } - } - } - companion object { const val TASK_ID: String = "taskId" } 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 829d4835a2..7edc08f9bf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt @@ -58,7 +58,7 @@ import org.groundplatform.ui.components.loireport.LoiReportAction fun DataCollectionScreen( viewModel: DataCollectionViewModel, onValidationError: (resId: Int) -> Unit, - onLoiReportAction: (LoiReportAction) -> Unit, + onReportExportError: () -> Unit, onExitConfirmed: () -> Unit, onOpenSettings: () -> Unit, onAwaitingPhotoCapture: (Boolean) -> Unit, @@ -73,6 +73,7 @@ fun DataCollectionScreen( is DataCollectionUiEffect.OpenSettings -> onOpenSettings() is DataCollectionUiEffect.SetAwaitingPhotoCapture -> onAwaitingPhotoCapture(effect.awaiting) is DataCollectionUiEffect.ShowValidationError -> onValidationError(effect.errorResId) + is DataCollectionUiEffect.ShowReportExportError -> onReportExportError() } } } @@ -80,7 +81,7 @@ fun DataCollectionScreen( DataCollectionContent( uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }, - onLoiReportAction = onLoiReportAction, + onLoiReportAction = { viewModel.onLoiReportAction(it) }, ) { readyState -> val tasks = readyState.tasks diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index f4221e6901..416d2e9b17 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -59,6 +59,8 @@ import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase import org.groundplatform.domain.usecases.submission.SubmitDataUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import timber.log.Timber sealed interface DataCollectionUiEffect { @@ -69,6 +71,8 @@ sealed interface DataCollectionUiEffect { data class SetAwaitingPhotoCapture(val awaiting: Boolean) : DataCollectionUiEffect data class ShowValidationError(val errorResId: Int) : DataCollectionUiEffect + + data object ShowReportExportError : DataCollectionUiEffect } /** View model for the Data Collection fragment. */ @@ -86,6 +90,7 @@ internal constructor( private val viewModelFactory: ViewModelFactory, private val dataCollectionInitializer: DataCollectionInitializer, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : AbstractViewModel() { private val _uiEffects = Channel(Channel.BUFFERED) @@ -200,6 +205,15 @@ internal constructor( } } + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = (uiState.value as? DataCollectionUiState.TaskSubmitted)?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(DataCollectionUiEffect.ShowReportExportError) + } + } + } + fun handleLoiNameAction(action: LoiNameAction, taskId: String) { when (action) { is LoiNameAction.Confirmed -> { diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt index 2e93bc5aa4..b0715119ed 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt @@ -75,6 +75,7 @@ class DataCollectionScreenTest { onExitConfirmed = onExitConfirmed, onOpenSettings = {}, onAwaitingPhotoCapture = {}, + onReportExportError = {}, ) } } From b85f4f9180e56eb317c6d8cc22501cbb7b1392b5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 16 Jun 2026 18:18:46 +0200 Subject: [PATCH 47/50] add HomeScreenMapContainerUiEffect for the VM effects --- .../HomeScreenMapContainerUiEffect.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt new file mode 100644 index 0000000000..f1f1336335 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.home.mapcontainer + +import androidx.annotation.StringRes +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData +import org.groundplatform.domain.model.Survey + +/** + * One-off events emitted by [HomeScreenMapContainerViewModel] for the host fragment to render + * (popups, navigation, dialogs). + */ +sealed interface HomeScreenMapContainerUiEffect { + data class ShowError(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class ShowInfo(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class NavigateToDataCollection(val data: DataCollectionEntryPointData) : + HomeScreenMapContainerUiEffect + + data class ShowDataSharingTerms( + val data: DataCollectionEntryPointData, + val terms: Survey.DataSharingTerms, + ) : HomeScreenMapContainerUiEffect +} \ No newline at end of file From 2f04c13dfd10cc6c17a5e52827b43673b39b2fe4 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 16 Jun 2026 18:19:35 +0200 Subject: [PATCH 48/50] move LoiReportExporter to HomeScreenMapContainerViewModel and update event handling --- .../HomeScreenMapContainerFragment.kt | 116 +++--------------- .../HomeScreenMapContainerScreen.kt | 2 +- .../HomeScreenMapContainerViewModel.kt | 86 +++++++++++++ 3 files changed, 104 insertions(+), 100 deletions(-) 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 97f2f3a82d..f249c00f20 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt @@ -22,12 +22,9 @@ 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 import org.groundplatform.android.ui.common.BaseMapViewModel @@ -41,21 +38,16 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY -import org.groundplatform.feature.pdf.LoiReportExporter -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). */ @AndroidEntryPoint class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { @Inject lateinit var ephemeralPopups: EphemeralPopups - @Inject lateinit var loiReportExporter: LoiReportExporter private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel private lateinit var homeScreenViewModel: HomeScreenViewModel @@ -69,13 +61,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { map.featureClicks.launchWhenStartedAndCollect { mapContainerViewModel.onFeatureClicked(it) } } - private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = - when (cardUiData) { - // LOI tasks are filtered out of the tasks list for pre-defined tasks. - is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 - is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() - } - private fun showDataSharingTermsDialog( cardUiData: DataCollectionEntryPointData, dataSharingTerms: Survey.DataSharingTerms, @@ -88,45 +73,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - /** Invoked when user clicks on the map cards to collect data. */ - private fun onCollectData(cardUiData: DataCollectionEntryPointData) { - if (!cardUiData.canCollectData) { - // Skip data collection screen if the user can't submit any data - // TODO: Revisit UX for displaying view only mode - // Issue URL: https://github.com/google/ground-android/issues/1667 - ephemeralPopups.ErrorPopup().show(getString(R.string.collect_data_viewer_error)) - return - } - if (!hasValidTasks(cardUiData)) { - // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. - ephemeralPopups.ErrorPopup().show(getString(R.string.no_tasks_error)) - return - } - - mapContainerViewModel - .getDataSharingTerms() - .onSuccess { terms -> - if (terms == null) { - // Data sharing terms already accepted or missing. - navigateToDataCollectionFragment(cardUiData) - } else { - showDataSharingTermsDialog(cardUiData, terms) - } - } - .onFailure { - Timber.e(it, "Failed to get data sharing terms") - ephemeralPopups - .ErrorPopup() - .show( - if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { - R.string.invalid_data_sharing_terms - } else { - R.string.something_went_wrong - } - ) - } - } - /** Invoked when user clicks delete on a site. */ private fun onDeleteSite(loiData: SelectedLoiSheetData) { mapContainerViewModel.deleteLoi(loiData.loi) @@ -165,13 +111,14 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { onJobComponentAction = { handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) }, - onLoiReportAction = { handleLoiReportAction(it) }, + onLoiReportAction = { mapContainerViewModel.onLoiReportAction(it) }, ) } } binding.bottomContainer.bringToFront() - showDataCollectionHint() + mapContainerViewModel.uiEffects.launchWhenStartedAndCollect { handleUiEffect(it) } + mapContainerViewModel.showDataCollectionHint() // LOIs associated with the survey have been synced to the local db by this point. We can // enable location lock if no LOIs exist or a previous camera position doesn't exist. @@ -192,7 +139,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { ) { when (action) { is JobMapComponentAction.OnAddDataClicked -> { - onCollectData(action.selectedLoi) + mapContainerViewModel.onCollectData(action.selectedLoi) } is JobMapComponentAction.OnDeleteSiteClicked -> { onDeleteSite(action.selectedLoi) @@ -205,10 +152,12 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { val jobs = (jobMapComponentState as? JobMapComponentState.AddLoiButton)?.jobs ?: (jobMapComponentState as? JobMapComponentState.JobSelectionModal)?.jobs - jobs?.firstOrNull { it.job == action.job }?.let { onCollectData(it) } + jobs?.firstOrNull { it.job == action.job }?.let { mapContainerViewModel.onCollectData(it) } } is JobMapComponentAction.OnAddLoiButtonClicked -> { - mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { onCollectData(it) } + mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { + mapContainerViewModel.onCollectData(it) + } } JobMapComponentAction.OnJobSelectionModalDismissed -> { mapContainerViewModel.setJobSelectionModalVisibility(false) @@ -216,49 +165,18 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - private fun handleLoiReportAction(action: LoiReportAction) { - val loiReport = - (mapContainerViewModel.jobMapComponentState.value as? JobMapComponentState.LoiSelected) - ?.loi - ?.loiReport - ?: run { - ephemeralPopups.ErrorPopup().unknownError() - return - } - lifecycleScope.launch { - loiReportExporter.export(loiReport, action).onFailure { - ephemeralPopups.ErrorPopup().unknownError() - } + private fun handleUiEffect(event: HomeScreenMapContainerUiEffect) { + when (event) { + is HomeScreenMapContainerUiEffect.ShowError -> + ephemeralPopups.ErrorPopup().show(event.messageId) + is HomeScreenMapContainerUiEffect.ShowInfo -> showInfoPopup(event.messageId) + is HomeScreenMapContainerUiEffect.NavigateToDataCollection -> + navigateToDataCollectionFragment(event.data) + is HomeScreenMapContainerUiEffect.ShowDataSharingTerms -> + showDataSharingTermsDialog(event.data, event.terms) } } - /** - * Displays a popup hint informing users how to begin collecting data. - * - * This method should only be called after view creation and should only trigger once per view - * create. - */ - private fun showDataCollectionHint() { - if (!this::mapContainerViewModel.isInitialized) { - return Timber.w("showDataCollectionHint() called before mapContainerViewModel initialized") - } - if (!this::binding.isInitialized) { - return Timber.w("showDataCollectionHint() called before binding initialized") - } - - // Decides which survey-related popup to show based on the current survey. - mapContainerViewModel.surveyUpdateFlow.launchWhenStartedAndCollectFirst { surveyProperties -> - surveyProperties.getInfoPopupMessageId()?.let { showInfoPopup(it) } - } - } - - private fun HomeScreenMapContainerViewModel.SurveyProperties.getInfoPopupMessageId(): Int? = - if (noLois && !addLoiPermitted) { - R.string.read_only_data_collection_hint - } else { - null - } - private fun showInfoPopup(messageId: Int) { ephemeralPopups .InfoPopup(binding.bottomContainer, messageId, EphemeralPopups.PopupDuration.LONG) diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt index b3bd0d30d2..e56cc9bb7b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt @@ -89,7 +89,7 @@ fun HomeScreenMapContainerScreen( JobMapComponent( state = jobComponentState, - onAction = onJobComponentAction, + onJobComponentAction = onJobComponentAction, onLoiReportAction = onLoiReportAction, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index 32250a83df..7ac0caeaec 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -22,6 +22,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.groundplatform.android.R import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.system.LocationManager @@ -46,6 +49,7 @@ import org.groundplatform.android.ui.common.BaseMapViewModel import org.groundplatform.android.ui.common.LocationOfInterestHelper import org.groundplatform.android.ui.common.SharedViewModel import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.Feature @@ -62,6 +66,9 @@ import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction +import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @SharedViewModel @@ -81,6 +88,7 @@ internal constructor( private val localValueStore: LocalValueStore, private val locationOfInterestHelper: LocationOfInterestHelper, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : BaseMapViewModel( locationManager, @@ -139,6 +147,9 @@ internal constructor( */ val jobMapComponentState: StateFlow + private val _uiEffects = Channel(Channel.BUFFERED) + val uiEffects: Flow = _uiEffects.receiveAsFlow() + init { // THIS SHOULD NOT BE CALLED ON CONFIG CHANGE @@ -328,4 +339,79 @@ internal constructor( featureClicked.value = null } } + + /** Exports the selected LOI's report as a PDF, or emits an error if it cannot be exported. */ + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = + (jobMapComponentState.value as? JobMapComponentState.LoiSelected)?.loi?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + } + } + + /** Invoked when user clicks on the map cards to collect data. */ + fun onCollectData(cardUiData: DataCollectionEntryPointData) { + viewModelScope.launch { + when { + !cardUiData.canCollectData -> + // Skip data collection screen if the user can't submit any data. + // TODO: Revisit UX for displaying view only mode + // Issue URL: https://github.com/google/ground-android/issues/1667 + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error) + ) + !hasValidTasks(cardUiData) -> + // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + else -> + getDataSharingTerms() + .onSuccess { terms -> + if (terms == null) { + // Data sharing terms already accepted or missing. + _uiEffects.send(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardUiData)) + } else { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardUiData, terms) + ) + } + } + .onFailure { + Timber.e(it, "Failed to get data sharing terms") + val messageId = + if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { + R.string.invalid_data_sharing_terms + } else { + R.string.something_went_wrong + } + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(messageId)) + } + } + } + } + + private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = + when (cardUiData) { + // LOI tasks are filtered out of the tasks list for pre-defined tasks. + is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 + is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() + } + + /** + * Displays a popup hint informing users how to begin collecting data. + * + * This method should only be called after view creation and should only trigger once per view + * create. + */ + fun showDataCollectionHint() { + viewModelScope.launch { + val properties = surveyUpdateFlow.first() + if (properties.noLois && !properties.addLoiPermitted) { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint) + ) + } + } + } } From 2a04d8100330910bf6613d64919765caf3ca415b Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 17 Jun 2026 14:22:26 +0200 Subject: [PATCH 49/50] add unit tests for data collection and home screen --- .../HomeScreenMapContainerViewModel.kt | 1 - .../DataCollectionFragmentTest.kt | 54 ++++++++- .../HomeScreenMapContainerViewModelTest.kt | 107 ++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index 7ac0caeaec..c78ce13b7d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -340,7 +340,6 @@ internal constructor( } } - /** Exports the selected LOI's report as a PDF, or emits an error if it cannot be exported. */ fun onLoiReportAction(action: LoiReportAction) { val loiReport = (jobMapComponentState.value as? JobMapComponentState.LoiSelected)?.loi?.loiReport diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt index abc8908c78..9c93dc97f1 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt @@ -26,6 +26,9 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import javax.inject.Inject +import kotlin.time.Clock import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -38,6 +41,7 @@ import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.di.PdfModule import org.groundplatform.android.getString import org.groundplatform.android.testrules.FragmentScenarioRule import org.groundplatform.android.ui.datacollection.tasks.point.DropPinTaskViewModel @@ -63,18 +67,21 @@ import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterfac import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.shadows.ShadowToast -import javax.inject.Inject -import kotlin.time.Clock @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest +@UninstallModules(PdfModule::class) @RunWith(RobolectricTestRunner::class) class DataCollectionFragmentTest : BaseHiltTest() { @get:Rule(order = 4) val composeTestRule = createComposeRule() @@ -88,6 +95,7 @@ class DataCollectionFragmentTest : BaseHiltTest() { @Inject lateinit var userRepository: UserRepositoryInterface @BindValue @Mock lateinit var mutationSyncWorkManager: MutationSyncWorkManager + @BindValue @Mock lateinit var loiReportExporter: LoiReportExporter lateinit var fragment: DataCollectionFragment @@ -708,6 +716,48 @@ class DataCollectionFragmentTest : BaseHiltTest() { assertTrue(state is DataCollectionUiState.TaskSubmitted) } + @Test + fun `onLoiReportAction shows an error when exporting the report fails`() = runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.failure(RuntimeException())) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(1) + assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo(getString(R.string.unexpected_error)) + } + + @Test + fun `onLoiReportAction does not show an error when exporting the report succeeds`() = + runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.success(Unit)) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(0) + } + @Test fun `Clicking done after triggering conditional task saves task data`() = runWithTestDispatcher { setupFragment() diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index ee7054f6b4..8fe2ac7262 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -27,11 +27,14 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB +import org.groundplatform.android.FakeData.DATA_SHARING_TERMS +import org.groundplatform.android.FakeData.JOB import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_FEATURE import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_LOI_REPORT import org.groundplatform.android.FakeData.SURVEY import org.groundplatform.android.FakeData.USER +import org.groundplatform.android.R import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.di.LocationOfInterestRepositoryModule import org.groundplatform.android.system.auth.FakeAuthenticationManager @@ -39,11 +42,13 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase +import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -198,6 +203,108 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { assertThat(result).isNull() } + @Test + fun `onCollectData emits ShowError when user cannot collect data`() = runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = false, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error)) + } + + @Test + fun `onCollectData emits ShowError when card has no valid tasks`() = runWithTestDispatcher { + val cardData = + SelectedLoiSheetData( + canCollectData = true, + loi = LOCATION_OF_INTEREST, + submissionCount = 0, + showDeleteLoiButton = false, + loiReport = null, + ) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + } + + @Test + fun `onCollectData emits ShowDataSharingTerms when terms not yet accepted`() = + runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardData, DATA_SHARING_TERMS) + ) + } + + @Test + fun `onCollectData emits NavigateToDataCollection when terms already accepted`() = + runWithTestDispatcher { + viewModel.grantDataSharingConsent() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardData)) + } + + @Test + fun `onCollectData emits ShowError when data sharing terms are invalid`() = + runWithTestDispatcher { + val survey = + SURVEY.copy( + id = "INVALID_TERMS_SURVEY", + dataSharingTerms = Survey.DataSharingTerms.Custom(""), + ) + remoteDataStore.surveys = listOf(survey) + activateSurvey(survey.id) + advanceUntilIdle() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.invalid_data_sharing_terms)) + } + + @Test + fun `showDataCollectionHint emits ShowInfo for read-only survey with no LOIs`() = + runWithTestDispatcher { + val readOnlySurvey = SURVEY.copy(id = "READ_ONLY_SURVEY", jobMap = mapOf(JOB.id to JOB)) + whenever(loiRepository.getValidLois(readOnlySurvey)).thenReturn(flowOf(setOf())) + remoteDataStore.surveys = listOf(readOnlySurvey) + activateSurvey(readOnlySurvey.id) + advanceUntilIdle() + + viewModel.showDataCollectionHint() + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint)) + } + + @Test + fun `onLoiReportAction emits ShowError when no LOI is selected`() = runWithTestDispatcher { + // No LOI is selected, so there is no report to export. + viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + companion object { private val BOUNDS = Bounds(Coordinates(-20.0, -20.0), Coordinates(-10.0, -10.0)) val CAMERA_POSITION = From 77e1f866cd4a238b366349ed002abecf3906a063 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 17 Jun 2026 16:04:47 +0200 Subject: [PATCH 50/50] update composable tests --- .../DataSubmissionConfirmationScreen.kt | 15 +++---- .../HomeScreenMapContainerUiEffect.kt | 2 +- .../mapcontainer/jobs/ShareLocationModal.kt | 20 ++++----- .../org/groundplatform/android/FakeData.kt | 9 +--- .../DataSubmissionConfirmationScreenTest.kt | 39 +++++++++++++++++ .../jobs/ShareLocationModalTest.kt | 43 +++++++++++++++++++ .../model/locationofinterest/LoiReport.kt | 2 +- .../usecases/GetLoiReportUseCaseTest.kt | 12 ++++-- 8 files changed, 110 insertions(+), 32 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 f41d88e03c..039c4c9b11 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt @@ -175,16 +175,15 @@ private fun ShareableContent( } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, - )} + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt index f1f1336335..a8fc15720f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt @@ -35,4 +35,4 @@ sealed interface HomeScreenMapContainerUiEffect { val data: DataCollectionEntryPointData, val terms: Survey.DataSharingTerms, ) : HomeScreenMapContainerUiEffect -} \ No newline at end of file +} 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 617a839276..54d17ab5c4 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -101,17 +101,15 @@ fun ShareLocationModal( } loiReport.submissionDetails?.let { - if (!it.submissions.isNullOrEmpty()) { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, - onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } TextButton( diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 986cdd2be7..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -129,14 +129,7 @@ object FakeData { ), ) ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = SURVEY.title, - userName = USER.displayName, - userEmail = USER.email, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, - submissions = null, - ), + submissionDetails = null, ) val LOCATION_OF_INTEREST_FEATURE = Feature( diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt index 0157c0f99e..d50912fc2a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -33,8 +34,10 @@ import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -135,6 +138,42 @@ class DataSubmissionConfirmationScreenTest { composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss is triggered when the close button is clicked`() { var dismissed = false 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 c126ae1b66..c5987d3283 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -30,9 +31,11 @@ import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.groundplatform.ui.theme.AppTheme +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -90,6 +93,46 @@ class ShareLocationModalTest { composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss callback is triggered when close button is clicked`() { var dismissed = false diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt index f558df72f5..1769ed44c2 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt @@ -29,6 +29,6 @@ data class LoiReport( val userName: String, val userEmail: String, val dateMillis: Long, - val submissions: List?, + val submissions: List, ) } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt index 507f7d86db..1a0def1f1b 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt @@ -18,6 +18,7 @@ package org.groundplatform.domain.usecases import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.groundplatform.domain.model.geometry.Coordinates @@ -312,6 +313,7 @@ class GetLoiReportUseCaseTest { @Test fun `Should populate loiName, userName and dateMillis from the inputs`() = runTest { userRepository.currentUser = FakeDataGenerator.newUser(displayName = "John Doe") + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) loiRepository.offlineLoi = loiRepository.offlineLoi.copy( lastModified = AuditInfo(user = userRepository.currentUser, clientTimestamp = 987654321L) @@ -329,6 +331,7 @@ class GetLoiReportUseCaseTest { fun `Should populate surveyName from the offline survey`() = runTest { surveyRepository.offlineSurveys = listOf(FakeDataGenerator.newSurvey(id = "surveyId", title = "Restoration areas")) + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! @@ -358,17 +361,20 @@ class GetLoiReportUseCaseTest { val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! - assertEquals(listOf("older", "middle", "newer"), loiReport.submissions?.map { it.id }) + assertEquals( + listOf("older", "middle", "newer"), + loiReport.submissionDetails!!.submissions.map { it.id }, + ) } @Test - fun `Should return an empty submissions list when no submissions exist`() = runTest { + fun `Should return null submission details when no submissions exist`() = runTest { submissionRepository.submissions = emptyList() val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! - assertEquals(emptyList(), loiReport.submissions) + assertNull(loiReport.submissionDetails) } private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport {