diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index 475f43cd6a..ab90ff792a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -97,6 +97,7 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { viewModel.onFeatureClicked(features = setOf(LOCATION_OF_INTEREST_FEATURE)) val state = viewModel.processJobMapComponentState().first() advanceUntilIdle() + val actualGeoJson = (state as JobMapComponentState.LoiSelected).loi.loiReport!!.geoJson assertThat(state) .isEqualTo( JobMapComponentState.LoiSelected( @@ -105,7 +106,11 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { loi = LOCATION_OF_INTEREST, submissionCount = 0, showDeleteLoiButton = true, - loiReport = LOCATION_OF_INTEREST_LOI_REPORT.copy(submissionDetails = null), + loiReport = + LOCATION_OF_INTEREST_LOI_REPORT.copy( + submissionDetails = null, + geoJson = actualGeoJson, + ), ) ) ) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index c1c08502fd..70f2b6cbee 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LocationOfInterest.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LocationOfInterest.kt index 4ff31fe787..7e209ac7d4 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LocationOfInterest.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LocationOfInterest.kt @@ -24,6 +24,7 @@ import org.groundplatform.domain.model.mutation.Mutation typealias LoiProperties = Map const val LOI_NAME_PROPERTY = "name" +const val LOI_ID_PROPERTY = "id" fun generateProperties(loiName: String? = null): LoiProperties = loiName?.let { mapOf(LOI_NAME_PROPERTY to it) } ?: mapOf() 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 7714903ed4..3ace93b6bb 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,6 +15,11 @@ */ package org.groundplatform.domain.usecases +import kotlin.time.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -27,8 +32,9 @@ import org.groundplatform.domain.model.geometry.LinearRing import org.groundplatform.domain.model.geometry.MultiPolygon import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.model.geometry.Polygon +import org.groundplatform.domain.model.locationofinterest.LOI_ID_PROPERTY import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY -import org.groundplatform.domain.model.locationofinterest.LoiProperties +import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.SubmissionRepositoryInterface @@ -61,10 +67,10 @@ class GetLoiReportUseCase( submissionRepositoryInterface.getSubmissions(loi).sortedByDescending { it.lastModified.clientTimestamp } + val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() val submissionDetails = if (submissions.isNotEmpty()) { val user = userRepositoryInterface.getAuthenticatedUser() - val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() LoiReport.SubmissionDetails( surveyName = surveyName, userName = user.displayName, @@ -72,12 +78,10 @@ class GetLoiReportUseCase( submissions = submissions, ) } else null + return LoiReport( loiName = loiName, - geoJson = - loi.geometry.toGeoJson( - loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } - ), + geoJson = loi.geometry.toGeoJson(loi = loi, surveyName = surveyName), submissionDetails = submissionDetails, ) } @@ -86,7 +90,7 @@ class GetLoiReportUseCase( * Converts a [Geometry] to its GeoJSON representation as defined by * [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946). */ - private fun Geometry.toGeoJson(loiProperties: LoiProperties): JsonObject { + private fun Geometry.toGeoJson(loi: LocationOfInterest, surveyName: String): JsonObject { val geometryJson = when (this) { is Point -> geoJsonObject(TYPE_POINT, coordinatesToPosition(coordinates)) @@ -99,10 +103,12 @@ class GetLoiReportUseCase( is MultiPolygon -> geoJsonObject(TYPE_MULTI_POLYGON, JsonArray(polygons.map { polygonToCoordinates(it) })) } + val properties = getLoiPropertiesMap(loi, surveyName) + return JsonObject( mapOf( KEY_TYPE to JsonPrimitive(TYPE_FEATURE), - KEY_PROPERTIES to JsonObject(loiProperties.mapValues { it.value.toJsonPrimitive() }), + KEY_PROPERTIES to JsonObject(properties), KEY_GEOMETRY to geometryJson, ) ) @@ -140,12 +146,45 @@ class GetLoiReportUseCase( return JsonUnquotedLiteral(value) } + private fun getLoiPropertiesMap( + loi: LocationOfInterest, + surveyName: String, + ): Map = buildMap { + loi.properties[LOI_NAME_PROPERTY]?.let { put(LOI_NAME_PROPERTY, it.toJsonPrimitive()) } + ?: loi.properties[LOI_ID_PROPERTY]?.let { put(LOI_ID_PROPERTY, it.toJsonPrimitive()) } + put(KEY_SURVEY, JsonPrimitive(surveyName)) + if (loi.isPredefined != true) { + put(KEY_DATE, JsonPrimitive(formatDateTime(loi.lastModified.clientTimestamp))) + } + } + + /** + * Formats an epoch-milliseconds timestamp as YYYYMMDD- HH:MM (24h) in the device's local time + * zone (e.g. 20260703 10:00). + */ + internal fun formatDateTime(epochMillis: Long): String { + val instant = Instant.fromEpochMilliseconds(epochMillis) + val local = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val fmt = LocalDateTime.Format { + year() + monthNumber() + day() + char(' ') + hour() + char(':') + minute() + } + return fmt.format(local) + } + private companion object { const val KEY_TYPE = "type" const val TYPE_FEATURE = "Feature" const val KEY_PROPERTIES = "properties" const val KEY_GEOMETRY = "geometry" const val KEY_COORDINATES = "coordinates" + const val KEY_SURVEY = "survey" + const val KEY_DATE = "date" const val TYPE_POINT = "Point" const val TYPE_LINE_STRING = "LineString" const val TYPE_POLYGON = "Polygon" diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt index f2a52959d4..66384e7b6e 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt @@ -21,6 +21,8 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.geometry.LineString @@ -55,19 +57,18 @@ class GetLoiReportUseCaseTest { properties = generateProperties("Point test"), ) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "Point test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Point test"), + geometry = + """ + { "type": "Point", "coordinates": [-89.000000, 41.000000] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test @@ -84,21 +85,20 @@ class GetLoiReportUseCaseTest { val loiReport = invokeUseCase(geometry = Polygon(shell), properties = generateProperties("Polygon test")) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "Polygon test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Polygon test"), + geometry = + """ + { "type": "Polygon", "coordinates": [ [[0.000000, 0.000000], [0.000000, 1.000000], [1.000000, 1.000000], [0.000000, 0.000000]] ] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test @@ -127,22 +127,21 @@ class GetLoiReportUseCaseTest { properties = generateProperties("Polygon with holes test"), ) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "Polygon with holes test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Polygon with holes test"), + geometry = + """ + { "type": "Polygon", "coordinates": [ [[0.000000, 0.000000], [0.000000, 10.000000], [10.000000, 10.000000], [0.000000, 0.000000]], [[2.000000, 2.000000], [2.000000, 3.000000], [3.000000, 3.000000], [2.000000, 2.000000]] ] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test @@ -171,22 +170,21 @@ class GetLoiReportUseCaseTest { properties = generateProperties("MultiPolygon test"), ) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "MultiPolygon test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "MultiPolygon test"), + geometry = + """ + { "type": "MultiPolygon", "coordinates": [ [[[0.000000, 0.000000], [0.000000, 1.000000], [1.000000, 1.000000], [0.000000, 0.000000]]], [[[5.000000, 5.000000], [5.000000, 6.000000], [6.000000, 6.000000], [5.000000, 5.000000]]] ] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test @@ -196,42 +194,63 @@ class GetLoiReportUseCaseTest { val loiReport = invokeUseCase(geometry = lineString, properties = generateProperties("LineString test")) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "LineString test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "LineString test"), + geometry = + """ + { "type": "LineString", "coordinates": [[20.000000, 10.000000], [40.000000, 30.000000], [60.000000, 50.000000]] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test - fun `Should get a report with empty properties when no name is provided`() = runTest { + fun `Should still include survey and date when no name is provided`() = runTest { val loiReport = invokeUseCase( geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), properties = generateProperties(), ) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(), + geometry = + """ + { "type": "Point", "coordinates": [0.000000, 0.000000] } - } - """ - .trimIndent() - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) + } + + @Test + fun `Should not include date for predefined LOIs`() = runTest { + val loiReport = + invokeUseCase( + geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), + properties = generateProperties("Predefined test"), + isPredefined = true, + ) + + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Predefined test", includeDate = false), + geometry = + """ + { + "type": "Point", + "coordinates": [0.000000, 0.000000] + } + """ + .trimIndent(), + ) } @Test @@ -265,12 +284,12 @@ class GetLoiReportUseCaseTest { val loiReport = invokeUseCase(geometry = lineString, properties = generateProperties("Rounding test")) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "Rounding test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Rounding test"), + geometry = + """ + { "type": "LineString", "coordinates": [ [2.987654, 1.123457], @@ -278,41 +297,63 @@ class GetLoiReportUseCaseTest { [6.987654, 5.123457] ] } - } - """ - .trimIndent() - - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test - fun `Should only include name property even if more are provided`() = runTest { - val properties = - generateProperties("Name test") + - mapOf("description" to "Should be removed", "extra" to "Also removed") - + fun `Should only include loiName, surveyName and date as properties`() = runTest { val loiReport = - invokeUseCase(geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), properties = properties) + invokeUseCase( + geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), + properties = + generateProperties("Properties test") + + mapOf("description" to "Should be removed", "extra" to "Also removed"), + ) - val expectedGeoJson = - """ - { - "type": "Feature", - "properties": {"name": "Name test"}, - "geometry": { + assertGeoJson( + loiReport, + properties = expectedProperties(name = "Properties test"), + geometry = + """ + { "type": "Point", "coordinates": [0.000000, 0.000000] } - } - """ - .trimIndent() - - assertEquals(Json.parseToJsonElement(expectedGeoJson), loiReport.geoJson) + """ + .trimIndent(), + ) } @Test - fun `Should populate loiName and userName from the inputs`() = runTest { - userRepository.currentUser = FakeDataGenerator.newUser(displayName = "John Doe") + fun `Should only include loiId, surveyName and date as properties, when no loiName is present`() = + runTest { + val loiReport = + invokeUseCase( + geometry = Point(Coordinates(lat = 0.0, lng = 0.0)), + properties = + mapOf("id" to "loiId", "description" to "Should be removed", "extra" to "Also removed"), + ) + + assertGeoJson( + loiReport, + properties = expectedProperties(id = "loiId"), + geometry = + """ + { + "type": "Point", + "coordinates": [0.000000, 0.000000] + } + """ + .trimIndent(), + ) + } + + @Test + fun `Should populate loiName, userName and userEmail from the inputs`() = runTest { + userRepository.currentUser = + FakeDataGenerator.newUser(displayName = "John Doe", email = "john@example.com") submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) val loiReport = @@ -320,6 +361,7 @@ class GetLoiReportUseCaseTest { assertEquals("Test LOI", loiReport.loiName) assertEquals("John Doe", loiReport.submissionDetails!!.userName) + assertEquals("john@example.com", loiReport.submissionDetails.userEmail) } @Test @@ -373,9 +415,53 @@ class GetLoiReportUseCaseTest { assertNull(loiReport.submissionDetails) } - private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { + private suspend fun invokeUseCase( + geometry: Geometry, + properties: LoiProperties, + isPredefined: Boolean? = false, + ): LoiReport { + surveyRepository.offlineSurveys = + listOf(FakeDataGenerator.newSurvey(id = "surveyId", title = SURVEY_TITLE)) loiRepository.offlineLoi = - loiRepository.offlineLoi.copy(geometry = geometry, properties = properties) + loiRepository.offlineLoi.copy( + geometry = geometry, + properties = properties, + isPredefined = isPredefined, + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = TEST_TIMESTAMP), + ) return getLoiReportUseCase.invoke("loiName", "loiId", "surveyId")!! } + + private fun expectedProperties( + name: String? = null, + id: String? = null, + includeDate: Boolean = true, + ): String { + val expectedDate = getLoiReportUseCase.formatDateTime(TEST_TIMESTAMP) + val properties = buildMap { + name?.let { put("name", it) } + id?.let { put("id", it) } + put("survey", SURVEY_TITLE) + if (includeDate) put("date", expectedDate) + } + return JsonObject(properties.mapValues { JsonPrimitive(it.value) }).toString() + } + + private fun assertGeoJson(loiReport: LoiReport, properties: String, geometry: String) { + val expected = + """ + { + "type": "Feature", + "properties": $properties, + "geometry": $geometry + } + """ + .trimIndent() + assertEquals(Json.parseToJsonElement(expected), loiReport.geoJson) + } + + private companion object { + const val SURVEY_TITLE = "Restoration areas" + const val TEST_TIMESTAMP = 0L + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44e1ad2209..5663021315 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ kotlinVersion = "2.3.21" kotlinxBom = "1.11.0" kspVersion = "2.3.9" kotlinxCollectionsImmutable = "0.5.1" +kotlinxDatetime = "0.8.0" ktfmtVersion = "0.26.0" lifecycleVersion = "2.11.0" markdownVersion = "0.7.5" @@ -157,6 +158,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "coroutinesVersion" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-bom = { module = "org.jetbrains.kotlinx:kotlinx-serialization-bom", version.ref = "kotlinxBom" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxBom" } kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf" }