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 + } + } +}