diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a58af7a0f2..7a24fb5633 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,7 @@ jobs:
run: ./gradlew checkCode
- name: Run unit tests
- run: ./gradlew testLocalDebugUnitTest jvmTest
+ run: ./gradlew testLocalDebugUnitTest jvmTest testAndroidHostTest
- name: Generate codecov report
run: ./gradlew jacocoLocalDebugUnitTestReport
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt
index 2c02880433..6b1b24b742 100644
--- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt
@@ -45,6 +45,9 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson
+import org.jetbrains.compose.resources.stringResource as multiplatformStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -154,7 +157,7 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport
GroundQrCode(
modifier = Modifier.align(Alignment.Center),
title = loiReport.loiName,
- footer = stringResource(R.string.scan_this_qr_to_download_geojson),
+ footer = multiplatformStringResource(Res.string.scan_this_qr_to_download_geojson),
content = loiReport.geoJson.toString(),
contentDescription = "QR code with LOI Geometry",
centerLogoPainter = painterResource(R.drawable.ground_logo),
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt
index f65fb84001..288ebf34c8 100644
--- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceItemView.kt
@@ -37,13 +37,14 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import org.groundplatform.android.R
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.other
import org.groundplatform.android.common.Constants
+import org.jetbrains.compose.resources.stringResource
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.domain.model.task.MultipleChoice
import org.groundplatform.domain.model.task.Option
@@ -148,7 +149,7 @@ private fun OtherTextField(
@Composable
private fun MultipleChoiceItem.toTextLabel() =
- AnnotatedString(if (isOtherOption) stringResource(id = R.string.other) else option.label)
+ AnnotatedString(if (isOtherOption) stringResource(Res.string.other) else option.label)
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt
index 7056b24d96..54e9c1b782 100644
--- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt
@@ -38,6 +38,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson
+import org.jetbrains.compose.resources.stringResource as multiplatformStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -85,7 +88,7 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) {
GroundQrCode(
modifier = Modifier.align(Alignment.Center),
title = loiReport.loiName,
- footer = stringResource(R.string.scan_this_qr_to_download_geojson),
+ footer = multiplatformStringResource(Res.string.scan_this_qr_to_download_geojson),
content = loiReport.geoJson.toString(),
contentDescription = "QR code with LOI Geometry",
centerLogoPainter = painterResource(R.drawable.ground_logo),
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index c81c31665b..edcc5cabb1 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -136,7 +136,6 @@
Encuesta de solo lectura
Sin presentaciones
Descartar
- Otro
- %d presentación
- %d presentaciones
@@ -236,7 +235,6 @@
Error al guardar
No se pudo guardar la foto capturada. Por favor, inténtalo de nuevo.
Compartir ubicación
- Escanea este código QR para ver el GeoJSON
Unirse a una encuesta
Código QR de encuesta no reconocido
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index da119fe15a..ffa0374d55 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -124,7 +124,6 @@
L’enquête est en lecture seule
Aucune soumission
Rejeter
- Autres
- %d soumission
- %d soumissions
@@ -217,7 +216,6 @@
Erreur d’enregistrement
Impossible d’enregistrer la photo capturée. Veuillez réessayer.
Partager l’emplacement
- Scannez ce code QR pour afficher le GeoJson
Rejoindre une enquête
Code QR d’enquête non reconnu
diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml
index e43b2d45db..fdf2d853fe 100644
--- a/app/src/main/res/values-lo/strings.xml
+++ b/app/src/main/res/values-lo/strings.xml
@@ -125,7 +125,6 @@
ແບບສຳຫຼວດເປັນແບບເບິ່ງຢ່າງດຽວ
ບໍ່ມີການສົ່ງຂໍ້ມູນ
ປິດແຈ້ງເຕືອນ
- ອື່ນໆ
- %d ການສົ່ງຂໍ້ມູນ
@@ -206,7 +205,6 @@
ຂໍ້ຜິດພາດໃນການບັນທຶກ
ບໍ່ສາມາດບັນທຶກຮູບພາບທີ່ຖ່າຍໄດ້. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.
ແບ່ງປັນຕຳແໜ່ງ
- ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON
ເຂົ້າຮ່ວມແບບສຳຫຼວດ
ບໍ່ຮັບຮູ້ລະຫັດ QR ແບບສຳຫຼວດນີ້
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 0366966baf..bb84fbc104 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -136,7 +136,6 @@
Inquérito apenas para leitura
Nenhuma submissão
Ignorar
- Outro
- %d submissão
- %d submissões
@@ -238,7 +237,6 @@
Erro ao salvar
Falha ao salvar a foto capturada. Por favor, tente novamente.
Partilhar localização
- Leia este código QR para visualizar o GeoJson
Aderir ao inquérito
Código QR de inquérito não reconhecido
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index ea37ef3513..f6c3a5d773 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -125,7 +125,6 @@
แบบสำรวจเป็นแบบอ่านอย่างเดียว
ไม่มีการส่งข้อมูล
ปิดข้อความ
- อื่น ๆ
- การส่งข้อมูล %d รายการ
@@ -208,7 +207,6 @@
ข้อผิดพลาดในการบันทึก
ไม่สามารถบันทึกรูปภาพที่ถ่ายได้ โปรดลองอีกครั้ง
แชร์ตำแหน่ง
- สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON
เข้าร่วมแบบสำรวจ
ไม่รู้จักรหัสคิวอาร์ของแบบสำรวจนี้
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 8c841f8e84..22655c74fc 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -124,7 +124,6 @@
Khảo sát chỉ được phép xem
Chưa có bài gửi nào
Đóng lại
- Khác
- %d bài gửi
@@ -210,7 +209,6 @@
Lỗi lưu
Không thể lưu ảnh đã chụp. Vui lòng thử lại.
Chia sẻ vị trí
- Quét mã QR này để xem GeoJSON
Tham gia khảo sát
Không nhận diện được mã QR khảo sát
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1bc95ed8e0..df3210ef8d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -140,7 +140,6 @@
Survey is read-only
No submissions
Dismiss
- Other
- %d submission
- %d submissions
@@ -234,7 +233,6 @@
Re-center
Share location
- Scan this QR code to view the GeoJson
Join survey
Unrecognized survey QR code
diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt
index 948e2553b0..d5c0226660 100644
--- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt
+++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt
@@ -24,6 +24,8 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -55,7 +57,7 @@ class DataSubmissionConfirmationScreenTest {
composeTestRule.onNodeWithText(LOI_REPORT.loiName).assertIsDisplayed()
composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed()
composeTestRule
- .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson))
+ .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson))
.performScrollTo()
.assertIsDisplayed()
composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().assertIsDisplayed()
@@ -79,7 +81,7 @@ class DataSubmissionConfirmationScreenTest {
composeTestRule.onNodeWithText(LOI_REPORT.loiName).assertIsDisplayed()
composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed()
composeTestRule
- .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson))
+ .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson))
.assertIsDisplayed()
composeTestRule.onNodeWithText(getString(R.string.close)).assertIsDisplayed()
}
diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt
index a9a7a2206f..eee9bda893 100644
--- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt
+++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt
@@ -21,6 +21,8 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -49,7 +51,7 @@ class ShareLocationModalTest {
composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed()
composeTestRule.onNodeWithTag(TEST_TAG_GROUND_QR_CODE).assertIsDisplayed()
composeTestRule
- .onNodeWithText(getString(R.string.scan_this_qr_to_download_geojson))
+ .onNodeWithText(getString(Res.string.scan_this_qr_to_download_geojson))
.performScrollTo()
.assertIsDisplayed()
composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().assertIsDisplayed()
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index a13cd1ba78..596ad02e9d 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -348,7 +348,7 @@ naming:
minimumFunctionNameLength: 3
FunctionNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/androidHostTest/**' ]
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
FunctionParameterNaming:
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt
index 82169d478c..9601ba9bfd 100644
--- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/task/Task.kt
@@ -49,4 +49,7 @@ data class Task(
CAPTURE_LOCATION,
INSTRUCTIONS,
}
+
+ fun isOmittedFromDocExport(): Boolean =
+ type == Type.DROP_PIN || type == Type.DRAW_AREA || type == Type.INSTRUCTIONS
}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
index a799d68a7d..1078b0d2f1 100644
--- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
@@ -15,8 +15,6 @@
*/
package org.groundplatform.domain.usecases
-import kotlin.math.absoluteValue
-import kotlin.math.round
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@@ -35,6 +33,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
import org.groundplatform.domain.repository.SurveyRepositoryInterface
import org.groundplatform.domain.repository.UserRepositoryInterface
+import org.groundplatform.domain.util.toFixedDecimals
/**
* Use case that generates a [LoiReport] containing the LOI geometry and properties as a GeoJSON.
@@ -128,12 +127,8 @@ class GetLoiReportUseCase(
/** Renders this [Double] as a JSON number with exactly 6 decimal digits. */
private fun Double.roundTo6Decimals(): JsonPrimitive {
- val scaled = round(this * DECIMAL_SCALE).toLong()
- val sign = if (scaled < 0) "-" else ""
- val absScaled = scaled.absoluteValue
- val intPart = absScaled / DECIMAL_SCALE
- val fracPart = (absScaled % DECIMAL_SCALE).toString().padStart(DECIMAL_DIGITS, '0')
- return JsonUnquotedLiteral("$sign$intPart.$fracPart")
+ val value = toFixedDecimals(DECIMAL_DIGITS)
+ return JsonUnquotedLiteral(value)
}
private companion object {
@@ -147,6 +142,5 @@ class GetLoiReportUseCase(
const val TYPE_POLYGON = "Polygon"
const val TYPE_MULTI_POLYGON = "MultiPolygon"
const val DECIMAL_DIGITS = 6
- const val DECIMAL_SCALE = 1_000_000L
}
}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt
new file mode 100644
index 0000000000..801fafb6be
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/NumberFormatter.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.domain.util
+
+import kotlin.math.absoluteValue
+import kotlin.math.round
+
+fun Double.toFixedDecimals(decimals: Int): String {
+ if (decimals <= 0) return round(this).toLong().toString()
+ var scale = 1L
+ repeat(decimals) { scale *= 10 }
+ val scaled = round(this * scale).toLong()
+ val sign = if (scaled < 0) "-" else ""
+ val absScaled = scaled.absoluteValue
+ val intPart = absScaled / scale
+ val fracPart = (absScaled % scale).toString().padStart(decimals, '0')
+ return "$sign$intPart.$fracPart"
+}
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 5d5429969e..5d71bdb988 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -30,10 +30,13 @@ kotlin {
compileSdk = libs.versions.androidCompileSdk.get().toInt()
minSdk = libs.versions.androidMinSdk.get().toInt()
androidResources.enable = true
+
+ withHostTest { isIncludeAndroidResources = true }
}
- val xcfName = "GroundUiKit"
+ jvm()
+ val xcfName = "GroundUiKit"
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = xcfName
@@ -44,6 +47,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
+ implementation(project(":core:domain"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
@@ -51,13 +55,27 @@ kotlin {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.components.resources)
implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.kotlinx.collections.immutable)
}
}
- commonTest { dependencies { implementation(libs.kotlin.test) } }
+ commonTest {
+ dependencies {
+ implementation(project(":core:testing"))
+ implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
androidMain { dependencies { implementation(libs.google.zxing) } }
+ val androidHostTest by getting {
+ dependencies {
+ implementation(libs.junit)
+ implementation(libs.robolectric)
+ }
+ }
+
iosMain { dependencies {} }
}
}
diff --git a/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/AndroidDateFormatterTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/AndroidDateFormatterTest.kt
new file mode 100644
index 0000000000..1780dd8fcc
--- /dev/null
+++ b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/AndroidDateFormatterTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.test.AfterTest
+import kotlin.test.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class AndroidDateFormatterTest {
+
+ private val formatter = AndroidDateFormatter(RuntimeEnvironment.getApplication())
+
+ private val millis = 987654321L
+ private val defaultLocale = Locale.getDefault()
+ private val defaultZone = TimeZone.getDefault()
+
+ @AfterTest
+ fun tearDown() {
+ Locale.setDefault(defaultLocale)
+ TimeZone.setDefault(defaultZone)
+ }
+
+ @Test
+ fun `formatDate correctly renders date with US locale and UTC timezone`() {
+ Locale.setDefault(Locale.US)
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ assertEquals("1/12/70", formatter.formatDate(millis))
+ }
+
+ @Test
+ fun `formatTime correctly renders time with US locale and UTC timezone`() {
+ Locale.setDefault(Locale.US)
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ assertEquals("10:20 AM", formatter.formatTime(millis))
+ }
+}
diff --git a/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/ComposeStringResolverTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/ComposeStringResolverTest.kt
new file mode 100644
index 0000000000..a880b5e1d4
--- /dev/null
+++ b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/util/ComposeStringResolverTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.pdf_altitude
+import ground_android.core.ui.generated.resources.skipped
+import kotlin.test.assertEquals
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ComposeStringResolverTest {
+
+ @Test
+ fun `resolves a plain string`() = runTest {
+ assertEquals("Skipped", ComposeStringResolver.resolve(Res.string.skipped))
+ }
+
+ @Test
+ fun `resolves a string with a format argument`() = runTest {
+ assertEquals("Altitude: 5m", ComposeStringResolver.resolve(Res.string.pdf_altitude, "5"))
+ }
+}
diff --git a/core/ui/src/androidHostTest/resources/robolectric.properties b/core/ui/src/androidHostTest/resources/robolectric.properties
new file mode 100644
index 0000000000..3c5d1ab94f
--- /dev/null
+++ b/core/ui/src/androidHostTest/resources/robolectric.properties
@@ -0,0 +1,6 @@
+# src/androidHostTest/resources/robolectric.properties
+# Pin Robolectric to an SDK that runs on the project's Java 17 toolchain.
+# Android SDK 36 (compileSdk) requires Java 21, which the toolchain doesn't use.
+# TODO: Remove the need for this file after upgrading Robolectric tests to API 33
+# Issue URL: https://github.com/google/ground-android/issues/2246
+sdk=30
\ No newline at end of file
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/AndroidDateFormatter.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/AndroidDateFormatter.android.kt
new file mode 100644
index 0000000000..3516b0f989
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/AndroidDateFormatter.android.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import android.content.Context
+import android.text.format.DateFormat
+import java.util.Date
+
+/**
+ * Android [DateFormatter] backed by [DateFormat], which respects system date/time settings (such as
+ * the user's 24-hour preference) in addition to locale rules.
+ */
+class AndroidDateFormatter(private val context: Context) : DateFormatter {
+
+ override fun formatDate(millis: Long): String =
+ DateFormat.getDateFormat(context).format(Date(millis))
+
+ override fun formatTime(millis: Long): String =
+ DateFormat.getTimeFormat(context).format(Date(millis))
+}
diff --git a/core/ui/src/commonMain/composeResources/values-es/strings.xml b/core/ui/src/commonMain/composeResources/values-es/strings.xml
index e7a4cf2234..1441bfe99a 100644
--- a/core/ui/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml
@@ -20,4 +20,13 @@
Compartir
Sitio: %1$s
Recolector de datos: %1$s
+ Escanea este código QR para ver el GeoJSON
+ Omitido
+ Otro
+ Altitud: %1$sm
+ Precisión: %1$sm
+ N
+ S
+ E
+ O
diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml
index a02bb387a4..04072375f2 100644
--- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml
@@ -19,4 +19,13 @@
Partager
Site: %1$s
Collecteur de données: %1$s
+ Scannez ce code QR pour afficher le GeoJson
+ Autres
+ Ignoré
+ Altitude: %1$sm
+ Précision: %1$sm
+ N
+ S
+ E
+ O
diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml
index 7cc6cc9c78..c95e27b274 100644
--- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml
@@ -19,4 +19,13 @@
ແບ່ງປັນ
ຈຸດເກັບຂໍ້ມູນ: %1$s
ຜູ້ເກັບຂໍ້ມູນ: %1$s
+ ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON
+ ອື່ນໆ
+ ຂ້າມໄປແລ້ວ
+ ຄວາມສູງ: %1$s ແມັດ
+ ຄວາມແມ່ນຍຳ: %1$s ແມັດ
+ ນ
+ ຕ
+ ຕ.ອ
+ ຕ.ຕ
diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml
index 3e790c5acf..bc6c073d85 100644
--- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml
@@ -20,4 +20,13 @@
Partilhar
Local: %1$s
Coletor de dados: %1$s
+ Leia este código QR para visualizar o GeoJson
+ Outro
+ Ignorado
+ Altitude: %1$sm
+ Precisão: %1$sm
+ N
+ S
+ E
+ O
diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml
index fb8107eb9e..e9c343f710 100644
--- a/core/ui/src/commonMain/composeResources/values-th/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml
@@ -19,4 +19,13 @@
แชร์
ไซต์: %1$s
ผู้เก็บข้อมูล: %1$s
+ สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON
+ อื่น ๆ
+ ข้าม
+ ความสูง: %1$s ม.
+ ความแม่นยำ: %1$s ม.
+ ท.เหนือ
+ ท.ใต้
+ ต.ออก
+ ต.ตก
diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml
index dac00c6a08..2a1ef5fe2f 100644
--- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml
@@ -19,4 +19,13 @@
Chia sẻ
Địa điểm: %1$s
Người thu thập dữ liệu: %1$s
+ Quét mã QR này để xem GeoJSON
+ Khác
+ Bỏ qua
+ Độ cao: %1$sm
+ Độ chính xác: %1$sm
+ Bắc
+ Nam
+ Đông
+ Tây
diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml
index 0176966ea5..d7a73b1469 100644
--- a/core/ui/src/commonMain/composeResources/values/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values/strings.xml
@@ -20,4 +20,13 @@
Share
Site: %1$s
Data collector: %1$s
+ Scan this QR code to view the GeoJson
+ Skipped
+ Other
+ Altitude: %1$sm
+ Accuracy: %1$sm
+ N
+ S
+ E
+ W
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt
new file mode 100644
index 0000000000..121886bb8f
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 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
+import ground_android.core.ui.generated.resources.other
+import ground_android.core.ui.generated.resources.pdf_accuracy
+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.math.absoluteValue
+import kotlin.math.round
+import org.groundplatform.domain.model.geometry.Point
+import org.groundplatform.domain.model.submission.CaptureLocationTaskData
+import org.groundplatform.domain.model.submission.DateTimeTaskData
+import org.groundplatform.domain.model.submission.MultipleChoiceTaskData
+import org.groundplatform.domain.model.submission.NumberTaskData
+import org.groundplatform.domain.model.submission.SkippedTaskData
+import org.groundplatform.domain.model.submission.TaskData
+import org.groundplatform.domain.model.submission.TextTaskData
+import org.groundplatform.domain.model.task.PhotoTaskData
+import org.groundplatform.domain.model.task.Task
+import org.groundplatform.domain.util.toFixedDecimals
+import org.groundplatform.ui.model.SubmissionPdfDocument.Answer
+import org.groundplatform.ui.util.DateFormatter
+import org.groundplatform.ui.util.StringResolver
+
+class TaskValueMapper(
+ private val strings: StringResolver,
+ private val dateFormatter: DateFormatter,
+) {
+
+ /** Maps [Task] and [TaskData] values to the [Answer] to be rendered in the submission PDF. */
+ suspend fun map(task: Task, value: TaskData): Answer =
+ when (value) {
+ is SkippedTaskData -> Answer.Text(listOf(strings.resolve(Res.string.skipped)))
+ is TextTaskData -> Answer.Text(listOf(value.text))
+ is NumberTaskData -> Answer.Text(listOf(value.number))
+ is DateTimeTaskData ->
+ Answer.Text(listOfNotNull(formatTaskDateTime(task, value.timeInMillis)))
+ is MultipleChoiceTaskData -> Answer.Text(formatMultipleChoice(task, value))
+ is CaptureLocationTaskData -> Answer.Text(formatCaptureLocation(value))
+ is PhotoTaskData -> Answer.Photo(value.remoteFilename)
+ else -> Answer.Text(emptyList())
+ }
+
+ private fun formatTaskDateTime(task: Task, millis: Long): String? =
+ when (task.type) {
+ Task.Type.DATE -> dateFormatter.formatDate(millis)
+ Task.Type.TIME -> dateFormatter.formatTime(millis)
+ else -> null
+ }
+
+ private suspend fun formatMultipleChoice(
+ task: Task,
+ value: MultipleChoiceTaskData,
+ ): List {
+ val options = task.multipleChoice?.options.orEmpty()
+ val selectedLabels =
+ value.getSelectedOptionsIdsExceptOther().map { id ->
+ options.firstOrNull { it.id == id }?.label ?: id
+ }
+ return if (value.isOtherTextSelected()) {
+ selectedLabels + "${strings.resolve(Res.string.other)}: ${value.getOtherText()}"
+ } else {
+ selectedLabels
+ }
+ }
+
+ /** Coordinates first, then optional altitude and accuracy lines. */
+ private suspend fun formatCaptureLocation(value: CaptureLocationTaskData): List {
+ val lines = mutableListOf(formatPoint(value.location))
+ value.altitude?.let { lines.add(strings.resolve(Res.string.pdf_altitude, formatMeters(it))) }
+ value.accuracy?.let { lines.add(strings.resolve(Res.string.pdf_accuracy, formatMeters(it))) }
+ return lines
+ }
+
+ private suspend fun formatPoint(point: Point): String {
+ val lat = point.coordinates.lat
+ val lng = point.coordinates.lng
+ val latDir =
+ if (lat >= 0) strings.resolve(Res.string.north) else strings.resolve(Res.string.south)
+ val lngDir =
+ if (lng >= 0) strings.resolve(Res.string.east) else strings.resolve(Res.string.west)
+ return "${formatDegrees(lat)} $latDir, ${formatDegrees(lng)} $lngDir"
+ }
+
+ @VisibleForTesting
+ fun formatDegrees(value: Double): String =
+ "${value.absoluteValue.toFixedDecimals(DEGREES_DECIMALS)}°"
+
+ @VisibleForTesting 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/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt
new file mode 100644
index 0000000000..2bc0b96325
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.model
+
+/**
+ * UI model for a submission PDF. Each property corresponds to a distinct visual section so that
+ * platform renderers (Android, iOS) can lay them out independently:
+ */
+data class SubmissionPdfDocument(
+ val header: Header,
+ val qrBlock: QrBlock,
+ val footer: Footer,
+ val table: Table,
+) {
+
+ data class Header(
+ val surveyLabel: String,
+ val surveyName: String,
+ val jobLabel: String,
+ val jobName: String,
+ val timestamp: String,
+ )
+
+ data class QrBlock(val scanCaption: String)
+
+ data class Table(val submissionLabel: String, val loiName: String, val rows: List)
+
+ data class Row(val question: String, val answer: Answer)
+
+ sealed interface Answer {
+ data class Text(val lines: List) : Answer
+
+ data class Photo(val remoteFilename: String) : Answer
+ }
+
+ data class Footer(
+ val dataCollectorLabel: String,
+ val dataCollectorName: String,
+ val userEmail: String,
+ )
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt
new file mode 100644
index 0000000000..aafd4ddff2
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+/**
+ * Locale-aware date and time formatting. Implemented as an interface so it can be injected in tests
+ * and provided per platform.
+ */
+interface DateFormatter {
+
+ /** Formats just the date portion of [millis] in the user's locale (medium style). */
+ fun formatDate(millis: Long): String
+
+ /** Formats just the time portion of [millis] in the user's locale (short style, no seconds). */
+ fun formatTime(millis: Long): String
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/StringResolver.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/StringResolver.kt
new file mode 100644
index 0000000000..1d9cd4e81c
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/StringResolver.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.ui.util
+
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.getString
+
+/**
+ * Resolves string resources to localized text.
+ *
+ * Abstracted behind an interface so display logic can be unit-tested with a fake, without a
+ * Compose/Skiko resource runtime on the test classpath.
+ */
+interface StringResolver {
+
+ suspend fun resolve(resource: StringResource): String
+
+ suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String
+}
+
+/** [StringResolver] backed by Compose Multiplatform resources. */
+object ComposeStringResolver : StringResolver {
+
+ override suspend fun resolve(resource: StringResource): String = getString(resource)
+
+ override suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String =
+ getString(resource, *formatArgs)
+}
diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt
new file mode 100644
index 0000000000..04b24f772d
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt
@@ -0,0 +1,173 @@
+/*
+ * 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 kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.runTest
+import org.groundplatform.domain.model.geometry.Coordinates
+import org.groundplatform.domain.model.geometry.Point
+import org.groundplatform.domain.model.submission.CaptureLocationTaskData
+import org.groundplatform.domain.model.submission.DateTimeTaskData
+import org.groundplatform.domain.model.submission.DropPinTaskData
+import org.groundplatform.domain.model.submission.MultipleChoiceTaskData
+import org.groundplatform.domain.model.submission.NumberTaskData
+import org.groundplatform.domain.model.submission.SkippedTaskData
+import org.groundplatform.domain.model.submission.TextTaskData
+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.testing.FakeDataGenerator
+import org.groundplatform.ui.model.SubmissionPdfDocument.Answer
+import org.groundplatform.ui.util.FakeDateFormatter
+import org.groundplatform.ui.util.FakeStringResolver
+
+class TaskValueMapperTest {
+
+ private val dateFormatter = FakeDateFormatter
+ private val stringResolver = FakeStringResolver
+ private val mapper = TaskValueMapper(strings = stringResolver, dateFormatter = dateFormatter)
+
+ @Test
+ fun `TEXT task maps to the same value`() = runTest {
+ assertEquals(
+ Answer.Text(listOf("free text")),
+ mapper.map(task(Task.Type.TEXT), TextTaskData("free text")),
+ )
+ }
+
+ @Test
+ fun `NUMBER task maps to the same value`() = runTest {
+ assertEquals(
+ Answer.Text(listOf("42")),
+ mapper.map(task(Task.Type.NUMBER), NumberTaskData("42")),
+ )
+ }
+
+ @Test
+ fun `Skipped value renders the skipped label`() = runTest {
+ assertEquals(
+ Answer.Text(listOf("skipped")),
+ mapper.map(task(Task.Type.TEXT), SkippedTaskData()),
+ )
+ }
+
+ @Test
+ fun `PHOTO task maps to a photo answer`() = runTest {
+ assertEquals(
+ Answer.Photo("path/to/photo.jpg"),
+ mapper.map(task(Task.Type.PHOTO), PhotoTaskData("path/to/photo.jpg")),
+ )
+ }
+
+ @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))
+ }
+
+ @Test
+ fun `DATE task renders date only`() = runTest {
+ val millis = 987654321L
+ assertEquals(
+ Answer.Text(listOf(dateFormatter.formatDate(millis))),
+ mapper.map(task(Task.Type.DATE), DateTimeTaskData(millis)),
+ )
+ }
+
+ @Test
+ fun `TIME task renders time only`() = runTest {
+ val millis = 987654321L
+ assertEquals(
+ Answer.Text(listOf(dateFormatter.formatTime(millis))),
+ mapper.map(task(Task.Type.TIME), DateTimeTaskData(millis)),
+ )
+ }
+
+ @Test
+ fun `non date or time task renders empty answer`() = runTest {
+ val millis = 987654321L
+ assertEquals(
+ Answer.Text(emptyList()),
+ mapper.map(task(Task.Type.NUMBER), DateTimeTaskData(millis)),
+ )
+ }
+
+ @Test
+ 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")),
+ mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value),
+ )
+ }
+
+ @Test
+ fun `MULTIPLE_CHOICE appends the other free text as a trailing line`() = runTest {
+ val other = "${MultipleChoiceTaskData.OTHER_PREFIX}custom${MultipleChoiceTaskData.OTHER_SUFFIX}"
+ val value = MultipleChoiceTaskData(multipleChoice(), selectedOptionIds = listOf("a", other))
+ assertEquals(
+ Answer.Text(listOf("Apple", "other: custom")),
+ mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value),
+ )
+ }
+
+ @Test
+ fun `CAPTURE_LOCATION renders coordinates with directions and altitude and accuracy`() = runTest {
+ val value =
+ CaptureLocationTaskData(Point(Coordinates(1.5, -2.25)), altitude = 10.0, accuracy = 3.0)
+
+ val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value)
+
+ val expected =
+ Answer.Text(
+ listOf(
+ "${mapper.formatDegrees(1.5)} north, ${mapper.formatDegrees(2.25)} west",
+ "pdf_altitude(${mapper.formatMeters(10.0)})",
+ "pdf_accuracy(${mapper.formatMeters(3.0)})",
+ )
+ )
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `CAPTURE_LOCATION omits altitude and accuracy when absent`() = runTest {
+ val value =
+ CaptureLocationTaskData(Point(Coordinates(-1.0, 2.0)), altitude = null, accuracy = null)
+
+ val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value)
+
+ assertEquals(
+ Answer.Text(listOf("${mapper.formatDegrees(1.0)} south, ${mapper.formatDegrees(2.0)} east")),
+ result,
+ )
+ }
+
+ private fun task(type: Task.Type, multipleChoice: MultipleChoice? = null) =
+ FakeDataGenerator.newTask(type = type, multipleChoice = multipleChoice)
+
+ private fun multipleChoice() =
+ MultipleChoice(
+ options =
+ persistentListOf(
+ Option(id = "a", code = "A", label = "Apple"),
+ Option(id = "b", code = "B", label = "Banana"),
+ ),
+ cardinality = MultipleChoice.Cardinality.SELECT_MULTIPLE,
+ )
+}
diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt
new file mode 100644
index 0000000000..a22a47e26d
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+/** [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/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt
new file mode 100644
index 0000000000..251041f9db
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import 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/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/IosDateFormatter.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/IosDateFormatter.ios.kt
new file mode 100644
index 0000000000..d4cd605c51
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/IosDateFormatter.ios.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDate
+import platform.Foundation.NSDateFormatter
+import platform.Foundation.NSDateFormatterMediumStyle
+import platform.Foundation.NSDateFormatterNoStyle
+import platform.Foundation.NSDateFormatterShortStyle
+import platform.Foundation.dateWithTimeIntervalSince1970
+
+/** iOS [DateFormatter] backed by [NSDateFormatter], which is locale- and settings-aware. */
+@OptIn(ExperimentalForeignApi::class)
+class IosDateFormatter : DateFormatter {
+
+ override fun formatDate(millis: Long): String =
+ NSDateFormatter()
+ .apply {
+ dateStyle = NSDateFormatterMediumStyle
+ timeStyle = NSDateFormatterNoStyle
+ }
+ .stringFromDate(millis.toNSDate())
+
+ override fun formatTime(millis: Long): String =
+ NSDateFormatter()
+ .apply {
+ dateStyle = NSDateFormatterNoStyle
+ timeStyle = NSDateFormatterShortStyle
+ }
+ .stringFromDate(millis.toNSDate())
+
+ private fun Long.toNSDate(): NSDate = NSDate.dateWithTimeIntervalSince1970(this / 1000.0)
+}
diff --git a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt b/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt
new file mode 100644
index 0000000000..edc40ef030
--- /dev/null
+++ b/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.components.qrcode
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+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")
diff --git a/core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt b/core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt
new file mode 100644
index 0000000000..86df99504a
--- /dev/null
+++ b/core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.resources
+
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+import kotlin.test.Test
+import kotlin.test.fail
+import org.w3c.dom.Element
+
+/**
+ * Validates the Compose string resources across locales. Android lint covers this for res/values,
+ * but these are Compose Multiplatform resources, so nothing else guards them.
+ */
+class ComposeStringResourcesTest {
+
+ // Matches positional (%1$s) and non-positional (%s, %d) format specifiers.
+ private val placeholderRegex = Regex("""%(\d+\$)?[a-zA-Z]""")
+
+ private val composeResourcesDir =
+ File("src/commonMain/composeResources").also {
+ require(it.isDirectory) { "Compose resources not found at ${it.absolutePath}" }
+ }
+
+ @Test
+ fun `validate translations`() {
+ val default = parseStrings(File(composeResourcesDir, "values/strings.xml"))
+
+ val issues = buildList {
+ for (localeDir in localeDirs()) {
+ val localized = parseStrings(File(localeDir, "strings.xml"))
+ val locale = localeDir.name
+
+ val missing = (default.keys - localized.keys).sorted()
+ if (missing.isNotEmpty()) {
+ add("[$locale] missing keys: ${missing.joinToString()}")
+ }
+
+ for ((key, value) in localized) {
+ val expected = default[key] ?: continue
+ if (placeholders(expected) != placeholders(value)) {
+ add(
+ "[$locale] \"$key\" placeholder mismatch: " +
+ "expected ${placeholders(expected)} but found ${placeholders(value)}"
+ )
+ }
+ }
+ }
+ }
+
+ if (issues.isNotEmpty()) {
+ fail("Compose string resource issues:\n" + issues.joinToString("\n"))
+ }
+ }
+
+ private fun placeholders(value: String): List =
+ placeholderRegex.findAll(value).map { it.value }.sorted().toList()
+
+ private fun localeDirs(): List =
+ composeResourcesDir
+ .listFiles { file -> file.isDirectory && file.name.startsWith("values-") }
+ ?.sortedBy { it.name } ?: emptyList()
+
+ private fun parseStrings(file: File): Map {
+ if (!file.exists()) return emptyMap()
+ val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
+ val nodes = doc.getElementsByTagName("string")
+ return (0 until nodes.length).associate {
+ val element = nodes.item(it) as Element
+ element.getAttribute("name") to element.textContent
+ }
+ }
+}