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..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,14 +99,7 @@ private fun DataCollectionContentCompletePreview() {
uiState =
DataCollectionUiState.TaskSubmitted(
loiReport =
- LoiReport(
- surveyName = "Test Survey",
- userName = "John Doe",
- dateMillis = Clock.System.now().toEpochMilliseconds(),
- loiName = "Point A",
- geoJson = JsonObject(mapOf()),
- submissions = emptyList()
- )
+ LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), 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..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
@@ -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,20 +163,22 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport
)
}
- loiReport.submissions?.let {
- SubmissionPdfItem(
- modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
- title = loiReport.surveyName,
- loiName = loiReport.loiName,
- userName = loiReport.userName,
- date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)),
- onItemClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- onShareClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- )
+ 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 */
+ },
+ )
+ }
}
}
}
@@ -185,9 +186,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 +201,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..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
@@ -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,20 +94,22 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) {
)
}
- loiReport.submissions?.let {
- SubmissionPdfItem(
- modifier = Modifier.fillMaxWidth(),
- title = loiReport.surveyName,
- loiName = loiReport.loiName,
- userName = loiReport.userName,
- date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)),
- onItemClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- onShareClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- )
+ 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 */
+ },
+ )
+ }
}
TextButton(
@@ -128,9 +129,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 +143,7 @@ private fun ShareLocationModalPreview() {
),
)
),
- submissions = emptyList(),
+ submissionDetails = null,
)
AppTheme {
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/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt
index 5e7892b7c8..986cdd2be7 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,14 @@ object FakeData {
),
)
),
- submissions = 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(
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..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
@@ -60,15 +60,19 @@ class GetLoiReportUseCase(
// 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 =
+ 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 {
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
index fa9c565cbd..8d9a120036 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()
@@ -29,6 +39,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..5d82702f54 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,26 @@ 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/build.gradle.kts b/core/ui/build.gradle.kts
index 5d71bdb988..5a997471c8 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 {
@@ -53,8 +51,7 @@ 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)
}
}
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/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
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/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt
similarity index 59%
rename from core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt
rename to feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt
index edc40ef030..03b460c2fe 100644
--- a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt
+++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.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.feature.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 [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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt
new file mode 100644
index 0000000000..c5ad3215a8
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.feature.pdf
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.groundplatform.feature.pdf.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)
+ } 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()
+ }
+ }
+ 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt
new file mode 100644
index 0000000000..49ef9047ab
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.pdf
+
+import org.groundplatform.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt
new file mode 100644
index 0000000000..f5b1717394
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt
new file mode 100644
index 0000000000..7f04629636
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.pdf
+
+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
+ * 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt
new file mode 100644
index 0000000000..79cad8b79e
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/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
new file mode 100644
index 0000000000..418ff725aa
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.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.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
+
+/** 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 =
+ 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(100)
+
+ 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))
+ }
+ }
+
+ /**
+ * 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.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/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 83%
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 2bc0b96325..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
@@ -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()
}
diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt
new file mode 100644
index 0000000000..b8e1015bd9
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt
new file mode 100644
index 0000000000..74e001cd68
--- /dev/null
+++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/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")
+ }
+}
diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt
similarity index 90%
rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt
rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt
index a22a47e26d..638e21aaae 100644
--- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt
+++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/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.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 {
diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt
similarity index 80%
rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt
rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt
index 251041f9db..429efe244a 100644
--- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt
+++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt
@@ -13,12 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.groundplatform.ui.util
+package org.groundplatform.feature.pdf.helpers
+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.
+ * [org.groundplatform.ui.util.StringResolver] for tests so display logic can be asserted without a
+ * Compose resource runtime.
*/
object FakeStringResolver : StringResolver {
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
new file mode 100644
index 0000000000..3f3939c6c8
--- /dev/null
+++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.mapper
+
+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
+
+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 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é/São/José",
+ userName = "Test?",
+ ),
+ ),
+ submission = FakeDataGenerator.newSubmission(),
+ )
+
+ assertEquals("CaféSãoJosé_ß_Test_$timestampSegment", request!!.fileName)
+ }
+
+ @Test
+ fun `file name is capped at 100 characters`() = runTest {
+ val request =
+ mapper.map(
+ loiReport =
+ FakeDataGenerator.newLoiReport(
+ loiName = "x",
+ submissionDetails =
+ FakeDataGenerator.newSubmissionDetails(surveyName = "a".repeat(300), userName = "y"),
+ ),
+ submission = FakeDataGenerator.newSubmission(),
+ )
+
+ assertEquals(100, 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()))
+ }
+}
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 81%
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..e146ad0f4f 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.helpers.FakeDateFormatter
+import org.groundplatform.feature.pdf.helpers.FakeStringResolver
+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
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/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt
new file mode 100644
index 0000000000..adadacec43
--- /dev/null
+++ b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.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.feature.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()
+}
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'