diff --git a/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt
new file mode 100644
index 0000000000..9fffa8726a
--- /dev/null
+++ b/app/src/main/java/org/groundplatform/android/di/PdfReportModule.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.android.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import org.groundplatform.android.BuildConfig
+import org.groundplatform.android.R
+import org.groundplatform.android.di.coroutines.IoDispatcher
+import org.groundplatform.ui.system.pdf.AndroidPdfRenderer
+import org.groundplatform.ui.system.pdf.PdfExportService
+import org.groundplatform.ui.system.pdf.PdfRenderer
+import org.groundplatform.ui.system.pdf.image.AndroidPdfImageProvider
+import org.groundplatform.ui.system.pdf.image.PdfImageProvider
+import org.groundplatform.ui.system.pdf.io.AndroidPdfOutputProvider
+import org.groundplatform.ui.system.pdf.io.AndroidPdfReportLauncher
+import org.groundplatform.ui.system.pdf.io.PdfOutputProvider
+import org.groundplatform.ui.system.pdf.io.PdfReportLauncher
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PdfReportModule {
+
+ @Provides
+ @Singleton
+ fun providePdfImageProvider(@ApplicationContext context: Context): PdfImageProvider =
+ AndroidPdfImageProvider(context = context, logoDrawableRes = R.drawable.ground_logo)
+
+ @Provides
+ @Singleton
+ fun providePdfOutputFactory(@ApplicationContext context: Context): PdfOutputProvider =
+ AndroidPdfOutputProvider(context)
+
+ @Provides @Singleton fun providePdfRenderer(): PdfRenderer = AndroidPdfRenderer()
+
+ @Provides
+ @Singleton
+ fun providePdfReportLauncher(@ApplicationContext context: Context): PdfReportLauncher =
+ AndroidPdfReportLauncher(context = context, fileProviderAuthority = BuildConfig.APPLICATION_ID)
+
+ @Provides
+ @Singleton
+ fun providePdfReportService(
+ imageProvider: PdfImageProvider,
+ renderer: PdfRenderer,
+ outputProvider: PdfOutputProvider,
+ launcher: PdfReportLauncher,
+ @IoDispatcher coroutineDispatcher: CoroutineDispatcher,
+ ): PdfExportService =
+ PdfExportService(
+ imageProvider = imageProvider,
+ renderer = renderer,
+ outputProvider = outputProvider,
+ launcher = launcher,
+ coroutineDispatcher = coroutineDispatcher,
+ )
+}
diff --git a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt
index 34c20a224d..ea84061442 100644
--- a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt
+++ b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt
@@ -38,7 +38,14 @@ object UseCaseModule {
locationOfInterestRepository: LocationOfInterestRepositoryInterface,
userRepository: UserRepositoryInterface,
surveyRepository: SurveyRepositoryInterface,
- ) = GetLoiReportUseCase(locationOfInterestRepository, userRepository, surveyRepository)
+ submissionRepository: SubmissionRepositoryInterface,
+ ) =
+ GetLoiReportUseCase(
+ locationOfInterestRepository = locationOfInterestRepository,
+ userRepositoryInterface = userRepository,
+ surveyRepositoryInterface = surveyRepository,
+ submissionRepositoryInterface = submissionRepository,
+ )
@Provides
fun providesUpdateUserSettingsUseCase(userRepository: UserRepositoryInterface) =
diff --git a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt
index e03fab65d1..352cbfdf34 100644
--- a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt
+++ b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt
@@ -112,4 +112,7 @@ constructor(
private suspend fun getPendingDeleteCount(loiId: String) =
localSubmissionStore.getPendingDeleteCount(loiId)
+
+ override suspend fun getSubmissions(loi: LocationOfInterest) =
+ localSubmissionStore.getSubmissions(loi, loi.job.id)
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt
index adfc69de01..96a666a2e4 100644
--- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt
@@ -20,8 +20,11 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.hilt.navigation.fragment.hiltNavGraphViewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.BackPressListener
@@ -29,12 +32,15 @@ import org.groundplatform.android.ui.common.EphemeralPopups
import org.groundplatform.android.ui.home.HomeScreenViewModel
import org.groundplatform.android.util.createComposeView
import org.groundplatform.android.util.openAppSettings
-import javax.inject.Inject
+import org.groundplatform.ui.components.loireport.LoiReportAction
+import org.groundplatform.ui.mapper.LoiReportMapper
+import org.groundplatform.ui.system.pdf.PdfExportService
/** Fragment allowing the user to collect data to complete a task. */
@AndroidEntryPoint
class DataCollectionFragment : AbstractFragment(), BackPressListener {
@Inject lateinit var popups: EphemeralPopups
+ @Inject lateinit var pdfExportService: PdfExportService
val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection)
@@ -55,6 +61,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener {
onExitConfirmed = { navigateBack() },
onOpenSettings = { requireActivity().openAppSettings() },
onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it },
+ onLoiReportAction = { handleLoiReportAction(it) },
)
}
@@ -91,6 +98,35 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener {
findNavController().navigateUp()
}
+ private fun handleLoiReportAction(action: LoiReportAction) {
+ val loiReport =
+ (viewModel.uiState.value as? DataCollectionUiState.TaskSubmitted)?.loiReport
+ ?: run {
+ popups.ErrorPopup().unknownError()
+ return
+ }
+ val submission =
+ loiReport.submissionDetails?.submissions?.firstOrNull()
+ ?: run {
+ popups.ErrorPopup().unknownError()
+ return
+ }
+
+ lifecycleScope.launch {
+ val request = LoiReportMapper.map(loiReport, submission)
+ if (request == null) {
+ popups.ErrorPopup().unknownError()
+ return@launch
+ }
+ val pdfAction =
+ when (action) {
+ is LoiReportAction.OnShareClicked -> PdfExportService.Action.Share
+ is LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open
+ }
+ pdfExportService.export(request, pdfAction)
+ }
+ }
+
companion object {
const val TASK_ID: String = "taskId"
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt
index 766035e189..829d4835a2 100644
--- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt
@@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.groundplatform.android.R
import org.groundplatform.android.ui.components.ConfirmationDialog
import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer
+import org.groundplatform.ui.components.loireport.LoiReportAction
/**
* The main screen for data collection, coordinating the task sequence and host UI.
@@ -57,6 +58,7 @@ import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer
fun DataCollectionScreen(
viewModel: DataCollectionViewModel,
onValidationError: (resId: Int) -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
onExitConfirmed: () -> Unit,
onOpenSettings: () -> Unit,
onAwaitingPhotoCapture: (Boolean) -> Unit,
@@ -75,7 +77,11 @@ fun DataCollectionScreen(
}
}
- DataCollectionContent(uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }) {
+ DataCollectionContent(
+ uiState = uiState,
+ onCloseClicked = { viewModel.onCloseClicked() },
+ onLoiReportAction = onLoiReportAction,
+ ) {
readyState ->
val tasks = readyState.tasks
if (tasks.isNotEmpty()) {
@@ -134,6 +140,7 @@ object DataCollectionScreenTestTags {
fun DataCollectionContent(
uiState: DataCollectionUiState,
onCloseClicked: () -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
pagerContent: @Composable (DataCollectionUiState.Ready) -> Unit,
) {
Scaffold(topBar = { DataCollectionToolbar(uiState, onCloseClicked) }) { innerPadding ->
@@ -153,7 +160,11 @@ fun DataCollectionContent(
ReadyContent { pagerContent(uiState) }
}
is DataCollectionUiState.TaskSubmitted -> {
- DataSubmissionConfirmationScreen(loiReport = uiState.loiReport) { onCloseClicked() }
+ DataSubmissionConfirmationScreen(
+ loiReport = uiState.loiReport,
+ onLoiReportAction = onLoiReportAction,
+ onDismissed = onCloseClicked,
+ )
}
}
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt
index e338cd6bc2..a5c15c07b2 100644
--- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt
@@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
-import kotlin.time.Clock
import kotlinx.serialization.json.JsonObject
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.domain.model.job.Job
@@ -38,7 +37,11 @@ private const val PAGER_CONTENT_TEXT = "Pager Content Area"
@ExcludeFromJacocoGeneratedReport
private fun DataCollectionContentLoadingPreview() {
AppTheme {
- DataCollectionContent(uiState = DataCollectionUiState.Loading, onCloseClicked = {}) {
+ DataCollectionContent(
+ uiState = DataCollectionUiState.Loading,
+ onCloseClicked = {},
+ onLoiReportAction = {},
+ ) {
Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) {
Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center))
}
@@ -58,6 +61,7 @@ private fun DataCollectionContentErrorPreview() {
cause = Error("Some error"),
),
onCloseClicked = {},
+ onLoiReportAction = {},
) {
Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) {
Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center))
@@ -83,6 +87,7 @@ private fun DataCollectionContentPreview() {
position = TaskPosition(0, 1, 3),
),
onCloseClicked = {},
+ onLoiReportAction = {},
) {
Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) {
Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center))
@@ -101,15 +106,13 @@ private fun DataCollectionContentCompletePreview() {
DataCollectionUiState.TaskSubmitted(
loiReport =
LoiReport(
- surveyName = "Test Survey",
- userName = "John Doe",
- dateMillis = Clock.System.now().toEpochMilliseconds(),
loiName = "Point A",
geoJson = JsonObject(mapOf()),
- submissions = emptyList()
+ submissionDetails = null,
)
),
onCloseClicked = {},
+ onLoiReportAction = {},
) {
Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) {
Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center))
diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt
index 2c02880433..f337d5d72d 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
@@ -57,6 +60,7 @@ import kotlinx.serialization.json.JsonPrimitive
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.domain.model.locationofinterest.LoiReport
+import org.groundplatform.ui.components.loireport.LoiReportAction
import org.groundplatform.ui.components.loireport.SubmissionPdfItem
import org.groundplatform.ui.components.qrcode.GroundQrCode
import org.groundplatform.ui.theme.AppTheme
@@ -66,6 +70,7 @@ fun DataSubmissionConfirmationScreen(
modifier: Modifier = Modifier,
loiReport: LoiReport? = null,
onDismissed: () -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
) {
val baseModifier =
modifier
@@ -86,12 +91,16 @@ fun DataSubmissionConfirmationScreen(
}
}
Spacer(modifier = Modifier.width(16.dp))
- ShareableContent(modifier = Modifier.weight(1f), loiReport = loiReport)
+ ShareableContent(
+ modifier = Modifier.weight(1f),
+ loiReport = loiReport,
+ onLoiReportAction = onLoiReportAction,
+ )
}
} else {
Column(modifier = baseModifier, horizontalAlignment = Alignment.CenterHorizontally) {
HeaderContent(modifier = Modifier.padding(vertical = 16.dp))
- ShareableContent(loiReport = loiReport)
+ ShareableContent(loiReport = loiReport, onLoiReportAction = onLoiReportAction)
OutlinedButton(modifier = Modifier.padding(vertical = 24.dp), onClick = { onDismissed() }) {
Text(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
@@ -134,7 +143,11 @@ private fun HeaderContent(modifier: Modifier = Modifier) {
}
@Composable
-private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport?) {
+private fun ShareableContent(
+ modifier: Modifier = Modifier,
+ loiReport: LoiReport?,
+ onLoiReportAction: (LoiReportAction) -> Unit,
+) {
val context = LocalContext.current
loiReport?.let {
@@ -154,26 +167,22 @@ 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),
)
}
- loiReport.submissions?.let {
+ loiReport.submissionDetails?.let {
SubmissionPdfItem(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
- title = loiReport.surveyName,
+ title = it.surveyName,
loiName = loiReport.loiName,
- userName = loiReport.userName,
- date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)),
- onItemClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- onShareClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
+ userName = it.userName,
+ date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)),
+ onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) },
+ onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) },
)
}
}
@@ -182,9 +191,6 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport
private val testLoiReport =
LoiReport(
- surveyName = "Test Survey",
- userName = "John Doe",
- dateMillis = Clock.System.now().toEpochMilliseconds(),
loiName = "Test LOI",
geoJson =
JsonObject(
@@ -200,19 +206,38 @@ private val testLoiReport =
),
)
),
- submissions = emptyList(),
+ submissionDetails =
+ LoiReport.SubmissionDetails(
+ surveyName = "Test Survey",
+ userName = "John Doe",
+ userEmail = "john.doe@example.com",
+ dateMillis = Clock.System.now().toEpochMilliseconds(),
+ submissions = emptyList(),
+ ),
)
@Composable
@Preview(showSystemUi = true)
@ExcludeFromJacocoGeneratedReport
private fun DataSubmissionConfirmationScreenPortraitPreview() {
- AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} }
+ AppTheme {
+ DataSubmissionConfirmationScreen(
+ loiReport = testLoiReport,
+ onLoiReportAction = {},
+ onDismissed = {},
+ )
+ }
}
@Composable
@Preview(heightDp = 320, widthDp = 800)
@ExcludeFromJacocoGeneratedReport
private fun DataSubmissionConfirmationScreenLandscapePreview() {
- AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} }
+ AppTheme {
+ DataSubmissionConfirmationScreen(
+ loiReport = testLoiReport,
+ onLoiReportAction = {},
+ onDismissed = {},
+ )
+ }
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/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/HomeScreenMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt
index 432ccdf284..04fa9ce9c0 100644
--- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt
@@ -22,9 +22,11 @@ import android.view.ViewGroup
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
+import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.databinding.BasemapLayoutBinding
import org.groundplatform.android.ui.common.AbstractMapContainerFragment
@@ -44,6 +46,9 @@ import org.groundplatform.android.util.renderComposableDialog
import org.groundplatform.android.util.setComposableContent
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
+import org.groundplatform.ui.components.loireport.LoiReportAction
+import org.groundplatform.ui.mapper.LoiReportMapper
+import org.groundplatform.ui.system.pdf.PdfExportService
import timber.log.Timber
/** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */
@@ -51,6 +56,7 @@ import timber.log.Timber
class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
@Inject lateinit var ephemeralPopups: EphemeralPopups
+ @Inject lateinit var pdfExportService: PdfExportService
private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel
private lateinit var homeScreenViewModel: HomeScreenViewModel
@@ -160,6 +166,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
onJobComponentAction = {
handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it)
},
+ onLoiReportAction = { handleLoiReportAction(it) },
)
}
}
@@ -210,6 +217,37 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
}
}
+ private fun handleLoiReportAction(action: LoiReportAction) {
+ val loiReport =
+ (mapContainerViewModel.jobMapComponentState.value as? JobMapComponentState.LoiSelected)
+ ?.loi
+ ?.loiReport
+ ?: run {
+ ephemeralPopups.ErrorPopup().unknownError()
+ return
+ }
+ val submission =
+ loiReport.submissionDetails?.submissions?.firstOrNull()
+ ?: run {
+ ephemeralPopups.ErrorPopup().unknownError()
+ return
+ }
+
+ lifecycleScope.launch {
+ val request = LoiReportMapper.map(loiReport, submission)
+ if (request == null) {
+ ephemeralPopups.ErrorPopup().unknownError()
+ return@launch
+ }
+ val pdfAction =
+ when (action) {
+ is LoiReportAction.OnShareClicked -> PdfExportService.Action.Share
+ is LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open
+ }
+ pdfExportService.export(request, pdfAction)
+ }
+ }
+
/**
* Displays a popup hint informing users how to begin collecting data.
*
diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt
index 1e83325ea0..b3bd0d30d2 100644
--- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt
@@ -42,6 +42,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio
import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState
import org.groundplatform.domain.model.job.Job
import org.groundplatform.domain.model.job.Style
+import org.groundplatform.ui.components.loireport.LoiReportAction
import org.groundplatform.ui.theme.AppTheme
@Composable
@@ -53,6 +54,7 @@ fun HomeScreenMapContainerScreen(
jobComponentState: JobMapComponentState,
onBaseMapAction: (BaseMapAction) -> Unit,
onJobComponentAction: (JobMapComponentAction) -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
) {
Box(modifier = modifier.fillMaxSize()) {
if (shouldShowMapActions) {
@@ -85,7 +87,11 @@ fun HomeScreenMapContainerScreen(
)
}
- JobMapComponent(state = jobComponentState, onAction = onJobComponentAction)
+ JobMapComponent(
+ state = jobComponentState,
+ onAction = onJobComponentAction,
+ onLoiReportAction = onLoiReportAction,
+ )
}
}
}
@@ -154,6 +160,7 @@ private fun HomeScreenMapContainerScreenPreview() {
shouldShowRecenter = true,
onBaseMapAction = {},
onJobComponentAction = {},
+ onLoiReportAction = {},
)
}
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt
index 5a73e513f6..9c182c79be 100644
--- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt
@@ -42,10 +42,15 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio
import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnJobSelected
import org.groundplatform.domain.model.job.Job
import org.groundplatform.domain.model.job.Style
+import org.groundplatform.ui.components.loireport.LoiReportAction
import org.groundplatform.ui.theme.AppTheme
@Composable
-fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit) {
+fun JobMapComponent(
+ state: JobMapComponentState,
+ onAction: (JobMapComponentAction) -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
+) {
when (state) {
is JobMapComponentState.LoiSelected -> {
var showShareLoiModal by rememberSaveable { mutableStateOf(false) }
@@ -59,7 +64,11 @@ fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentActio
)
if (showShareLoiModal && state.loi.loiReport != null) {
- ShareLocationModal(state.loi.loiReport) { showShareLoiModal = false }
+ ShareLocationModal(
+ loiReport = state.loi.loiReport,
+ onLoiReportAction = onLoiReportAction,
+ onDismiss = { showShareLoiModal = false },
+ )
}
}
is JobMapComponentState.AddLoiButton -> {
@@ -136,7 +145,9 @@ private fun JobMapComponentPreview() {
),
)
)
- )
- ) {}
+ ),
+ onAction = {},
+ onLoiReportAction = {},
+ )
}
}
diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt
index 7056b24d96..252e9c83a5 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
@@ -50,13 +53,18 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.groundplatform.android.R
import org.groundplatform.domain.model.locationofinterest.LoiReport
+import org.groundplatform.ui.components.loireport.LoiReportAction
import org.groundplatform.ui.components.loireport.SubmissionPdfItem
import org.groundplatform.ui.components.qrcode.GroundQrCode
import org.groundplatform.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) {
+fun ShareLocationModal(
+ loiReport: LoiReport,
+ onDismiss: () -> Unit,
+ onLoiReportAction: (LoiReportAction) -> Unit,
+) {
val context = LocalContext.current
Dialog(
@@ -85,26 +93,22 @@ 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),
)
}
- loiReport.submissions?.let {
+ loiReport.submissionDetails?.let {
SubmissionPdfItem(
modifier = Modifier.fillMaxWidth(),
- title = loiReport.surveyName,
+ title = it.surveyName,
loiName = loiReport.loiName,
- userName = loiReport.userName,
- date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)),
- onItemClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
- onShareClick = {
- /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */
- },
+ userName = it.userName,
+ date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)),
+ onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) },
+ onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) },
)
}
@@ -125,9 +129,6 @@ private fun ShareLocationModalPreview() {
val testLoiReport =
LoiReport(
loiName = "Test LOI",
- surveyName = "Test Survey",
- userName = "John Doe",
- dateMillis = Clock.System.now().toEpochMilliseconds(),
geoJson =
JsonObject(
mapOf(
@@ -142,12 +143,19 @@ private fun ShareLocationModalPreview() {
),
)
),
- submissions = emptyList(),
+ submissionDetails =
+ LoiReport.SubmissionDetails(
+ surveyName = "Test Survey",
+ userName = "John Doe",
+ userEmail = "john.doe@example.com",
+ dateMillis = Clock.System.now().toEpochMilliseconds(),
+ submissions = emptyList(),
+ ),
)
AppTheme {
Surface(modifier = Modifier.fillMaxSize()) {
- ShareLocationModal(loiReport = testLoiReport, onDismiss = {})
+ ShareLocationModal(loiReport = testLoiReport, onDismiss = {}, onLoiReportAction = {})
}
}
}
diff --git a/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/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index e58780065b..6500c99a78 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -19,4 +19,7 @@
+
\ No newline at end of file
diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt
index 5e7892b7c8..e858c36b16 100644
--- a/app/src/test/java/org/groundplatform/android/FakeData.kt
+++ b/app/src/test/java/org/groundplatform/android/FakeData.kt
@@ -111,10 +111,7 @@ object FakeData {
val LOCATION_OF_INTEREST_LOI_REPORT =
LoiReport(
- surveyName = SURVEY.title,
loiName = "Unnamed point",
- userName = USER.displayName,
- dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp,
geoJson =
JsonObject(
mapOf(
@@ -132,7 +129,7 @@ object FakeData {
),
)
),
- submissions = null,
+ submissionDetails = null,
)
val LOCATION_OF_INTEREST_FEATURE =
Feature(
diff --git a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt
index cc26d06a15..65377363fe 100644
--- a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt
+++ b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt
@@ -27,6 +27,7 @@ import org.groundplatform.domain.model.locationofinterest.LocationOfInterest
import org.groundplatform.domain.model.mutation.Mutation
import org.groundplatform.domain.model.mutation.SubmissionMutation
import org.groundplatform.domain.model.submission.DraftSubmission
+import org.groundplatform.domain.model.submission.Submission
import org.groundplatform.domain.model.submission.TextTaskData
import org.groundplatform.domain.model.submission.ValueDelta
import org.groundplatform.domain.model.task.Task
@@ -216,6 +217,21 @@ class SubmissionRepositoryTest {
assertThat(repository.getPendingCreateCount(loi.id)).isEqualTo(7)
}
+ @Test
+ fun `getSubmissions returns submissions for the LOI's job from the local store`() = runTest {
+ val expected = listOf(TEST_SUBMISSION)
+ whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(expected)
+
+ assertThat(repository.getSubmissions(TEST_LOI)).isEqualTo(expected)
+ }
+
+ @Test
+ fun `getSubmissions returns empty list when local store has no submissions`() = runTest {
+ whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(emptyList())
+
+ assertThat(repository.getSubmissions(TEST_LOI)).isEmpty()
+ }
+
private suspend fun setupMocks(
uuid: String = TEST_UUID,
loi: LocationOfInterest? = TEST_LOI,
@@ -263,5 +279,13 @@ class SubmissionRepositoryTest {
deltas = TEST_DELTAS,
currentTaskId = TEST_CURRENT_TASK_ID,
)
+ val TEST_SUBMISSION: Submission =
+ FakeDataGenerator.newSubmission(
+ surveyId = TEST_SURVEY.id,
+ locationOfInterest = TEST_LOI,
+ job = TEST_JOB,
+ created = AuditInfo(TEST_USER),
+ lastModified = AuditInfo(TEST_USER),
+ )
}
}
diff --git a/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..0203d0d543 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()
}
@@ -138,10 +140,7 @@ class DataSubmissionConfirmationScreenTest {
private companion object {
private val LOI_REPORT =
LoiReport(
- surveyName = "Test Survey",
loiName = "Test LOI",
- userName = "John Doe",
- dateMillis = 987654321L,
geoJson =
JsonObject(
mapOf(
@@ -156,7 +155,14 @@ class DataSubmissionConfirmationScreenTest {
),
)
),
- submissions = emptyList(),
+ submissionDetails =
+ LoiReport.SubmissionDetails(
+ surveyName = "Test Survey",
+ userName = "John Doe",
+ userEmail = "john.doe@example.com",
+ dateMillis = 987654321L,
+ submissions = emptyList(),
+ ),
)
}
}
diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt
index 57e2fa1254..bea437a77a 100644
--- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt
+++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt
@@ -125,10 +125,7 @@ class LoiJobSheetTest {
private fun getLoiReport(name: String): LoiReport =
LoiReport(
- surveyName = "Test Survey",
loiName = name,
- userName = "John Doe",
- dateMillis = 987654321L,
geoJson =
JsonObject(
mapOf(
@@ -143,7 +140,7 @@ class LoiJobSheetTest {
),
)
),
- submissions = null,
+ submissionDetails = null,
)
companion object {
diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt
index a9a7a2206f..938961f5f5 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()
@@ -94,10 +96,7 @@ class ShareLocationModalTest {
const val LOI_NAME = "Test Loi"
val LOI_REPORT =
LoiReport(
- surveyName = "Test Survey",
loiName = LOI_NAME,
- userName = "John Doe",
- dateMillis = 987654321L,
geoJson =
JsonObject(
mapOf(
@@ -112,7 +111,14 @@ class ShareLocationModalTest {
),
)
),
- submissions = null,
+ submissionDetails =
+ LoiReport.SubmissionDetails(
+ surveyName = "Test Survey",
+ userName = "John Doe",
+ userEmail = "john.doe@example.com",
+ dateMillis = 987654321L,
+ submissions = emptyList(),
+ ),
)
}
}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt
index 5c43e198e4..0cc256b74f 100644
--- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt
@@ -20,10 +20,15 @@ import org.groundplatform.domain.model.submission.Submission
/** Represents the data collected for a specific LOI which can be downloaded and shared. */
data class LoiReport(
- val surveyName: String,
val loiName: String,
- val userName: String,
- val dateMillis: Long,
val geoJson: JsonObject,
- val submissions: List?,
-)
+ val submissionDetails: SubmissionDetails?
+) {
+ data class SubmissionDetails(
+ val surveyName: String,
+ val userName: String,
+ val userEmail: String,
+ val dateMillis: Long,
+ val submissions: List?,
+ )
+}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/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/repository/SubmissionRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt
index 0b426d9749..7b198accb6 100644
--- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt
@@ -55,4 +55,10 @@ interface SubmissionRepositoryInterface {
suspend fun getTotalSubmissionCount(loi: LocationOfInterest): Int
suspend fun getPendingCreateCount(loiId: String): Int
+
+ /**
+ * Returns all submissions recorded for the given LOI. Includes synced submissions and locally
+ * pending CREATE mutations that have not yet been uploaded.
+ */
+ suspend fun getSubmissions(loi: LocationOfInterest): List
}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
index a799d68a7d..493f420520 100644
--- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt
@@ -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
@@ -33,8 +31,10 @@ import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.domain.model.locationofinterest.LoiProperties
import org.groundplatform.domain.model.locationofinterest.LoiReport
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
+import org.groundplatform.domain.repository.SubmissionRepositoryInterface
import org.groundplatform.domain.repository.SurveyRepositoryInterface
import org.groundplatform.domain.repository.UserRepositoryInterface
+import org.groundplatform.domain.util.toFixedDecimals
/**
* Use case that generates a [LoiReport] containing the LOI geometry and properties as a GeoJSON.
@@ -45,33 +45,40 @@ class GetLoiReportUseCase(
private val locationOfInterestRepository: LocationOfInterestRepositoryInterface,
private val userRepositoryInterface: UserRepositoryInterface,
private val surveyRepositoryInterface: SurveyRepositoryInterface,
+ private val submissionRepositoryInterface: SubmissionRepositoryInterface,
) {
/**
* Returns a [LoiReport] for the given LOI, or `null` if it does not exist.
*
- * @param loiName the identifier of the location of interest.
+ * @param loiName the name of the location of interest
+ * @param loiId the identifier of the location of interest.
* @param surveyId the identifier of the survey the LOI belongs to.
* @throws IllegalStateException if the LOI geometry is a bare [LinearRing].
*/
suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? {
- val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId)
- val user = userRepositoryInterface.getAuthenticatedUser()
- val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty()
- val submissions = null // To be implemented in a follow-up on
- // https://github.com/google/ground-android/issues/3715
- return loi?.let {
- LoiReport(
- surveyName = surveyName,
- loiName = loiName,
- userName = user.displayName,
- dateMillis = it.lastModified.clientTimestamp,
- geoJson =
- it.geometry.toGeoJson(
- it.properties.filter { property -> property.key == LOI_NAME_PROPERTY }
- ),
- submissions = submissions,
- )
- }
+ val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null
+ val submissions =
+ submissionRepositoryInterface.getSubmissions(loi).sortedBy { it.lastModified.clientTimestamp }
+ val submissionDetails =
+ if (submissions.isNotEmpty()) {
+ val user = userRepositoryInterface.getAuthenticatedUser()
+ val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty()
+ LoiReport.SubmissionDetails(
+ surveyName = surveyName,
+ userName = user.displayName,
+ userEmail = user.email,
+ dateMillis = loi.lastModified.clientTimestamp,
+ submissions = submissions,
+ )
+ } else null
+ return LoiReport(
+ loiName = loiName,
+ geoJson =
+ loi.geometry.toGeoJson(
+ loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY }
+ ),
+ submissionDetails = submissionDetails,
+ )
}
/**
@@ -128,12 +135,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 +150,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/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt
index d802241110..27f47dbe6a 100644
--- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt
+++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt
@@ -33,6 +33,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport
import org.groundplatform.domain.model.locationofinterest.generateProperties
import org.groundplatform.testing.FakeDataGenerator
import org.groundplatform.testing.FakeLocationOfInterestRepository
+import org.groundplatform.testing.FakeSubmissionRepository
import org.groundplatform.testing.FakeSurveyRepository
import org.groundplatform.testing.FakeUserRepository
@@ -41,8 +42,9 @@ class GetLoiReportUseCaseTest {
private val loiRepository = FakeLocationOfInterestRepository()
private val userRepository = FakeUserRepository()
private val surveyRepository = FakeSurveyRepository()
+ private val submissionRepository = FakeSubmissionRepository()
private val getLoiReportUseCase =
- GetLoiReportUseCase(loiRepository, userRepository, surveyRepository)
+ GetLoiReportUseCase(loiRepository, userRepository, surveyRepository, submissionRepository)
@Test
fun `Should get a report with the correct geoJson for a Point`() = runTest {
@@ -334,6 +336,41 @@ class GetLoiReportUseCaseTest {
assertEquals("Restoration areas", loiReport.surveyName)
}
+ @Test
+ fun `Should return submissions ordered by lastModified clientTimestamp`() = runTest {
+ val older =
+ FakeDataGenerator.newSubmission(
+ id = "older",
+ lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 100L),
+ )
+ val middle =
+ FakeDataGenerator.newSubmission(
+ id = "middle",
+ lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 200L),
+ )
+ val newer =
+ FakeDataGenerator.newSubmission(
+ id = "newer",
+ lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 300L),
+ )
+ submissionRepository.submissions = listOf(newer, older, middle)
+
+ val loiReport =
+ getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!!
+
+ assertEquals(listOf("older", "middle", "newer"), loiReport.submissions?.map { it.id })
+ }
+
+ @Test
+ fun `Should return an empty submissions list when no submissions exist`() = runTest {
+ submissionRepository.submissions = emptyList()
+
+ val loiReport =
+ getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!!
+
+ assertEquals(emptyList(), loiReport.submissions)
+ }
+
private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport {
loiRepository.offlineLoi =
loiRepository.offlineLoi.copy(geometry = geometry, properties = properties)
diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt
index 44af15f04e..5ac3fc3710 100644
--- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt
+++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt
@@ -34,6 +34,8 @@ import org.groundplatform.domain.model.mutation.SubmissionMutation
import org.groundplatform.domain.model.settings.MeasurementUnits
import org.groundplatform.domain.model.settings.UserSettings
import org.groundplatform.domain.model.submission.DraftSubmission
+import org.groundplatform.domain.model.submission.Submission
+import org.groundplatform.domain.model.submission.SubmissionData
import org.groundplatform.domain.model.submission.ValueDelta
import org.groundplatform.domain.model.task.Condition
import org.groundplatform.domain.model.task.MultipleChoice
@@ -132,6 +134,25 @@ object FakeDataGenerator {
currentTaskId = currentTaskId,
)
+ fun newSubmission(
+ id: String = "submission id",
+ surveyId: String = "survey id",
+ locationOfInterest: LocationOfInterest = newLocationOfInterest(),
+ job: Job = newJob(),
+ created: AuditInfo = AuditInfo(newUser()),
+ lastModified: AuditInfo = AuditInfo(newUser()),
+ data: SubmissionData = SubmissionData(),
+ ): Submission =
+ Submission(
+ id = id,
+ surveyId = surveyId,
+ locationOfInterest = locationOfInterest,
+ job = job,
+ created = created,
+ lastModified = lastModified,
+ data = data,
+ )
+
fun newTask(
id: String = "taskId",
type: Task.Type = Task.Type.TEXT,
diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt
index 8cfdcc4cac..399ae61171 100644
--- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt
@@ -18,6 +18,7 @@ package org.groundplatform.testing
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.locationofinterest.LocationOfInterest
import org.groundplatform.domain.model.submission.DraftSubmission
+import org.groundplatform.domain.model.submission.Submission
import org.groundplatform.domain.model.submission.ValueDelta
import org.groundplatform.domain.repository.SubmissionRepositoryInterface
@@ -26,6 +27,7 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface {
var latestDraftSubmissionId: String = ""
var pendingCreateCount: Int = 0
var pendingDeleteCount: Int = 0
+ var submissions: List = emptyList()
var onSaveSubmissionCall = FakeCall {}
override suspend fun saveSubmission(
@@ -75,6 +77,8 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface {
override suspend fun getPendingCreateCount(loiId: String): Int = pendingCreateCount
+ override suspend fun getSubmissions(loi: LocationOfInterest): List = submissions
+
data class SaveSubmissionParams(
val surveyId: String,
val loiId: String,
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 5d5429969e..9bcec44bdc 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -44,6 +44,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
+ implementation(project(":core:domain"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
@@ -51,12 +52,21 @@ kotlin {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.components.resources)
implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.kotlinx.collections.immutable)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
}
}
commonTest { dependencies { implementation(libs.kotlin.test) } }
- androidMain { dependencies { implementation(libs.google.zxing) } }
+ androidMain {
+ dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.exifinterface)
+ implementation(libs.google.zxing)
+ }
+ }
iosMain { dependencies {} }
}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt
index d8aaa505eb..c89755a4c8 100644
--- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt
@@ -25,7 +25,7 @@ import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
-actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap {
+actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap {
val hints =
mapOf(
EncodeHintType.ERROR_CORRECTION to
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt
new file mode 100644
index 0000000000..6b8bc38159
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/AndroidPdfRenderer.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+import android.graphics.pdf.PdfDocument
+import java.io.File
+import org.groundplatform.ui.model.SubmissionPdfDocument
+import org.groundplatform.ui.system.pdf.image.PdfImageSet
+
+/**
+ * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in
+ * [PdfWriter].
+ */
+class AndroidPdfRenderer : PdfRenderer {
+
+ override suspend fun render(
+ document: SubmissionPdfDocument,
+ images: PdfImageSet,
+ outputPath: String,
+ ) {
+ val pdf = PdfDocument()
+ try {
+ with(PdfWriter(pdf, images)) {
+ setHeader(document.header)
+ setFooter(document.footer)
+ drawQrBlock(document.qrBlock)
+ drawTable(document.table)
+ finalizePage()
+ }
+ File(outputPath).outputStream().use { pdf.writeTo(it) }
+ } finally {
+ pdf.close()
+ }
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt
new file mode 100644
index 0000000000..6ba4fa69e0
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/Cursor.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+import org.groundplatform.ui.system.pdf.PdfConfig.MARGIN
+import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_HEIGHT
+
+/** Tracks the current vertical draw position on a page and the space reserved for the footer. */
+internal class Cursor(private val pageHeight: Int = PAGE_HEIGHT, private val margin: Int = MARGIN) {
+ /** Space kept clear above the bottom margin for the footer; set once the footer is known. */
+ var footerReserve: Float = 0f
+
+ var y: Float = margin.toFloat()
+ private set
+
+ fun reset() {
+ y = margin.toFloat()
+ }
+
+ fun moveTo(absoluteY: Float) {
+ y = absoluteY
+ }
+
+ fun advance(delta: Float) {
+ y += delta
+ }
+
+ /** Whether a block of the given [height] still fits above the footer reserve on this page. */
+ fun fits(height: Float): Boolean = y + height <= pageHeight - margin - footerReserve
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt
new file mode 100644
index 0000000000..ed41a87d4b
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/PdfWriter.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import android.text.Layout
+import android.text.StaticLayout
+import android.text.TextPaint
+import android.text.TextUtils
+import android.util.SizeF
+import androidx.core.graphics.withTranslation
+import org.groundplatform.ui.model.SubmissionPdfDocument
+import org.groundplatform.ui.model.SubmissionPdfDocument.Answer
+import org.groundplatform.ui.model.SubmissionPdfDocument.Footer
+import org.groundplatform.ui.model.SubmissionPdfDocument.Header
+import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock
+import org.groundplatform.ui.system.pdf.PdfConfig.BODY_SIZE
+import org.groundplatform.ui.system.pdf.PdfConfig.BORDER_WIDTH
+import org.groundplatform.ui.system.pdf.PdfConfig.CAPTION_SIZE
+import org.groundplatform.ui.system.pdf.PdfConfig.CELL_PADDING
+import org.groundplatform.ui.system.pdf.PdfConfig.FOOTER_TOP_GAP
+import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_BOTTOM_GAP
+import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_COLUMN_GAP
+import org.groundplatform.ui.system.pdf.PdfConfig.HEADER_SIZE
+import org.groundplatform.ui.system.pdf.PdfConfig.LINE_SPACING
+import org.groundplatform.ui.system.pdf.PdfConfig.MARGIN
+import org.groundplatform.ui.system.pdf.PdfConfig.MAX_FOOTER_LINES
+import org.groundplatform.ui.system.pdf.PdfConfig.MAX_HEADER_VALUE_LINES
+import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_HEIGHT
+import org.groundplatform.ui.system.pdf.PdfConfig.PAGE_WIDTH
+import org.groundplatform.ui.system.pdf.PdfConfig.PHOTO_MAX_HEIGHT
+import org.groundplatform.ui.system.pdf.PdfConfig.QR_SIZE
+import org.groundplatform.ui.system.pdf.PdfConfig.TABLE_QUESTION_RATIO
+import org.groundplatform.ui.system.pdf.image.PdfImage
+import org.groundplatform.ui.system.pdf.image.PdfImageSet
+
+/**
+ * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down.
+ * Holds the mutable drawing state (current page, [Cursor], shared paints) shared by all sections.
+ */
+internal class PdfWriter(private val pdf: PdfDocument, private val images: PdfImageSet) {
+ private val headerPaint = textPaint(HEADER_SIZE, bold = true)
+ private val bodyPaint = textPaint(BODY_SIZE, bold = false)
+ private val captionPaint = textPaint(CAPTION_SIZE, bold = false)
+ private val strokePaint =
+ Paint().apply {
+ style = Paint.Style.STROKE
+ strokeWidth = BORDER_WIDTH
+ isAntiAlias = true
+ }
+
+ /** Bilinear filtering + dithering so down-scaled photos stay smooth. */
+ private val photoPaint =
+ Paint().apply {
+ isFilterBitmap = true
+ isAntiAlias = true
+ isDither = true
+ }
+
+ private val cursor = Cursor()
+ private var pageIndex = 0
+ private var page: PdfDocument.Page? = null
+
+ /** Top-Y of the table on the current page, or null if no table is in progress on this page. */
+ private var tableTopOnPage: Float? = null
+
+ private var header: Header? = null
+ private var footerLayouts: FooterLayouts? = null
+
+ fun setHeader(header: Header) {
+ this.header = header
+ }
+
+ /** Pre-lays out the footer columns so its height is known up front. */
+ fun setFooter(footer: Footer) {
+ val cols = threeColumnLayout()
+ footerLayouts =
+ FooterLayouts(
+ label = staticLayout(footer.dataCollectorLabel, headerPaint, cols.colWidth, maxLines = 1),
+ name =
+ staticLayout(
+ footer.dataCollectorName,
+ bodyPaint,
+ cols.colWidth,
+ Layout.Alignment.ALIGN_CENTER,
+ MAX_FOOTER_LINES,
+ ),
+ email =
+ staticLayout(
+ footer.userEmail,
+ bodyPaint,
+ cols.colWidth,
+ Layout.Alignment.ALIGN_OPPOSITE,
+ MAX_FOOTER_LINES,
+ ),
+ leftX = cols.leftX,
+ centerX = cols.centerX,
+ rightX = cols.rightX,
+ )
+ cursor.footerReserve = footerLayouts!!.height + FOOTER_TOP_GAP
+ }
+
+ fun finalizePage() {
+ flushTableDivider()
+ drawFooter()
+ page?.also { pdf.finishPage(it) }
+ page = null
+ }
+
+ private fun canvas(): Canvas = (page ?: beginPage()).canvas
+
+ private fun ensurePage() {
+ if (page == null) beginPage()
+ }
+
+ private fun beginPage(): PdfDocument.Page {
+ val info = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, ++pageIndex).create()
+ return pdf.startPage(info).also {
+ page = it
+ cursor.reset()
+ drawHeader()
+ }
+ }
+
+ private fun newPageIfShort(spaceNeeded: Float) {
+ if (!cursor.fits(spaceNeeded)) {
+ finalizePage()
+ beginPage()
+ }
+ }
+
+ /** Survey | Job | Timestamp drawn at the top of every page. */
+ private fun drawHeader() {
+ val header = header ?: return
+ val cols = threeColumnLayout()
+ val top = cursor.y
+ val surveyBottom =
+ drawLabeledColumn(header.surveyLabel, header.surveyName, cols.leftX, cols.colWidth, top)
+ val jobBottom =
+ drawLabeledColumn(
+ header.jobLabel,
+ header.jobName,
+ cols.centerX,
+ cols.colWidth,
+ top,
+ Layout.Alignment.ALIGN_CENTER,
+ )
+ val timestampBottom =
+ drawText(
+ header.timestamp,
+ cols.rightX,
+ top,
+ cols.colWidth,
+ bodyPaint,
+ Layout.Alignment.ALIGN_OPPOSITE,
+ MAX_HEADER_VALUE_LINES,
+ )
+ cursor.moveTo(maxOf(surveyBottom, jobBottom, timestampBottom) + HEADER_BOTTOM_GAP)
+ }
+
+ /** Draws a bold [label] above its [value] in one header column; returns the column's bottom Y. */
+ private fun drawLabeledColumn(
+ label: String,
+ value: String,
+ x: Float,
+ width: Int,
+ top: Float,
+ alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
+ ): Float {
+ val afterLabel = drawText(label, x, top, width, headerPaint, alignment)
+ return drawText(
+ value,
+ x,
+ afterLabel + LINE_SPACING,
+ width,
+ bodyPaint,
+ alignment,
+ MAX_HEADER_VALUE_LINES,
+ )
+ }
+
+ /** QR code + scan caption, right-aligned. */
+ fun drawQrBlock(block: QrBlock) {
+ ensurePage()
+ val x = (PAGE_WIDTH - MARGIN - QR_SIZE).toFloat()
+ var y = cursor.y
+ images[PdfImageSet.ImageRef.Qr]?.let { qr ->
+ canvas().drawImage(qr, RectF(x, y, x + QR_SIZE, y + QR_SIZE), null)
+ y += QR_SIZE
+ }
+ y += LINE_SPACING
+ y = drawText(block.scanCaption, x, y, QR_SIZE, captionPaint, Layout.Alignment.ALIGN_CENTER)
+ cursor.moveTo(y + LINE_SPACING * 2)
+ }
+
+ /** Data Collector | Name | Email anchored to the bottom of every page. */
+ private fun drawFooter() {
+ val footer = footerLayouts ?: return
+ val top = PAGE_HEIGHT - MARGIN - footer.height
+ drawStaticLayout(footer.label, footer.leftX, top)
+ drawStaticLayout(footer.name, footer.centerX, top)
+ drawStaticLayout(footer.email, footer.rightX, top)
+ }
+
+ /** Pre-laid-out footer columns and their X positions, sized once in [setFooter]. */
+ private class FooterLayouts(
+ val label: StaticLayout,
+ val name: StaticLayout,
+ val email: StaticLayout,
+ val leftX: Float,
+ val centerX: Float,
+ val rightX: Float,
+ ) {
+ val height: Float = maxOf(label.height, name.height, email.height).toFloat()
+ }
+
+ private fun threeColumnLayout(): ThreeColumns {
+ val usable = PAGE_WIDTH - 2 * MARGIN
+ val colWidth = (usable - 2 * HEADER_COLUMN_GAP) / 3
+ val leftX = MARGIN.toFloat()
+ val centerX = leftX + colWidth + HEADER_COLUMN_GAP
+ val rightX = centerX + colWidth + HEADER_COLUMN_GAP
+ return ThreeColumns(leftX, centerX, rightX, colWidth)
+ }
+
+ private data class ThreeColumns(
+ val leftX: Float,
+ val centerX: Float,
+ val rightX: Float,
+ val colWidth: Int,
+ )
+
+ fun drawTable(table: SubmissionPdfDocument.Table) {
+ val rows = table.rows.takeIf { it.isNotEmpty() } ?: return
+ ensurePage()
+ val width = PAGE_WIDTH - 2 * MARGIN
+ val x = MARGIN.toFloat()
+ cursor.advance(LINE_SPACING * 2)
+ val label = "${table.submissionLabel}: ${table.loiName}"
+ cursor.moveTo(drawText(label, x, cursor.y, width, headerPaint))
+ cursor.advance(LINE_SPACING)
+ rows.forEach { row ->
+ when (val answer = row.answer) {
+ is Answer.Text -> drawTableRow(row.question, answer.lines.joinToString("\n"), null)
+ is Answer.Photo ->
+ drawTableRow(
+ row.question,
+ answerText = "",
+ photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)],
+ )
+ }
+ }
+ flushTableDivider()
+ }
+
+ private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) {
+ val questionLayout = staticLayout(questionText, bodyPaint, TABLE.questionTextWidth)
+ val answerLayout =
+ if (answerText.isEmpty()) null else staticLayout(answerText, bodyPaint, TABLE.answerTextWidth)
+ val photoSize = photo?.let { fitInside(it, TABLE.answerTextWidth, PHOTO_MAX_HEIGHT) }
+ val rowHeight = computeRowHeight(questionLayout, answerLayout, photoSize)
+
+ newPageIfShort(rowHeight)
+ val left = MARGIN.toFloat()
+ if (tableTopOnPage == null) {
+ tableTopOnPage = cursor.y
+ canvas().drawLine(left, cursor.y, left + TABLE.width, cursor.y, strokePaint)
+ }
+
+ val top = cursor.y
+ val midX = left + TABLE.questionColWidth
+
+ drawStaticLayout(questionLayout, left + CELL_PADDING, top + CELL_PADDING)
+ drawAnswerCell(midX, top, answerLayout, photo, photoSize)
+ cursor.advance(rowHeight)
+
+ canvas().drawLine(left, cursor.y, left + TABLE.width, cursor.y, strokePaint)
+ }
+
+ private fun computeRowHeight(
+ questionLayout: StaticLayout,
+ answerLayout: StaticLayout?,
+ photoSize: SizeF?,
+ ): Float {
+ val answerHeight = answerLayout?.height?.toFloat() ?: 0f
+ val photoHeight = photoSize?.height ?: 0f
+ val photoSpacing = if (photoSize != null && answerLayout != null) LINE_SPACING else 0f
+ return maxOf(questionLayout.height.toFloat(), answerHeight + photoHeight + photoSpacing) +
+ 2 * CELL_PADDING
+ }
+
+ /** Draws the single vertical divider for the current page's table slice and clears the marker. */
+ private fun flushTableDivider() {
+ val top = tableTopOnPage ?: return
+ val midX = MARGIN + TABLE.questionColWidth.toFloat()
+ canvas().drawLine(midX, top, midX, cursor.y, strokePaint)
+ tableTopOnPage = null
+ }
+
+ private fun drawAnswerCell(
+ midX: Float,
+ top: Float,
+ answerLayout: StaticLayout?,
+ photo: PdfImage?,
+ photoSize: SizeF?,
+ ) {
+ val cellLeft = midX + CELL_PADDING
+ var y = top + CELL_PADDING
+ answerLayout?.let {
+ drawStaticLayout(it, cellLeft, y)
+ y += it.height + LINE_SPACING
+ }
+ if (photo != null && photoSize != null) {
+ // Centre horizontally within the answer cell.
+ val photoX = cellLeft + (TABLE.answerTextWidth - photoSize.width) / 2f
+ canvas()
+ .drawImage(
+ photo,
+ RectF(photoX, y, photoX + photoSize.width, y + photoSize.height),
+ photoPaint,
+ )
+ }
+ }
+
+ /** Lays out [text] and draws it at ([x], [y]); returns the Y just below the drawn text. */
+ private fun drawText(
+ text: String,
+ x: Float,
+ y: Float,
+ maxWidth: Int,
+ paint: TextPaint,
+ alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
+ maxLines: Int = Int.MAX_VALUE,
+ ): Float {
+ if (text.isEmpty()) return y
+ val layout = staticLayout(text, paint, maxWidth, alignment, maxLines)
+ drawStaticLayout(layout, x, y)
+ return y + layout.height
+ }
+
+ private fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) {
+ canvas().withTranslation(x, y) { layout.draw(this) }
+ }
+
+ /** A text paint at [size] points, [bold] for labels. */
+ private fun textPaint(size: Float, bold: Boolean): TextPaint =
+ TextPaint().apply {
+ textSize = size
+ isAntiAlias = true
+ if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+
+ /**
+ * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a
+ * single long value can't grow the layout unboundedly.
+ */
+ private fun staticLayout(
+ text: String,
+ paint: TextPaint,
+ maxWidth: Int,
+ alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
+ maxLines: Int = Int.MAX_VALUE,
+ ): StaticLayout =
+ StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth)
+ .setAlignment(alignment)
+ .setLineSpacing(LINE_SPACING, 1f)
+ .apply {
+ if (maxLines != Int.MAX_VALUE) {
+ setMaxLines(maxLines)
+ setEllipsize(TextUtils.TruncateAt.END)
+ }
+ }
+ .build()
+
+ /**
+ * Scales [image] to fit within [maxWidth] x [maxHeight], keeping aspect ratio, never upscaling.
+ */
+ private fun fitInside(image: PdfImage, maxWidth: Int, maxHeight: Int): SizeF {
+ val scale = minOf(maxWidth.toFloat() / image.width, maxHeight.toFloat() / image.height, 1f)
+ return SizeF(image.width * scale, image.height * scale)
+ }
+
+ /** Draws [image] scaled to fill [dst]. */
+ private fun Canvas.drawImage(image: PdfImage, dst: RectF, paint: Paint?) {
+ drawBitmap(image.bitmap, Rect(0, 0, image.bitmap.width, image.bitmap.height), dst, paint)
+ }
+}
+
+/** Column widths for the question/answer table. */
+private val TABLE = run {
+ val tableWidth = PAGE_WIDTH - 2 * MARGIN
+ val questionColWidth = (tableWidth * TABLE_QUESTION_RATIO).toInt()
+ object {
+ val width = tableWidth
+ val questionColWidth = questionColWidth
+ val questionTextWidth = questionColWidth - 2 * CELL_PADDING
+ val answerTextWidth = tableWidth - questionColWidth - 2 * CELL_PADDING
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt
new file mode 100644
index 0000000000..67aa3cc8c5
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/AndroidPdfImageProvider.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.image
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Matrix
+import android.os.Environment
+import androidx.annotation.DrawableRes
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.exifinterface.media.ExifInterface
+import java.io.File
+import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION
+import org.groundplatform.ui.components.qrcode.generateQrBitmap
+
+/**
+ * Android implementation of [PdfImageProvider].
+ *
+ * @param context application context used for resource access and file lookups.
+ * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding.
+ */
+class AndroidPdfImageProvider(
+ private val context: Context,
+ @DrawableRes private val logoDrawableRes: Int,
+) : PdfImageProvider {
+
+ override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet {
+ val images = mutableMapOf()
+ val bitmapsToRelease = mutableListOf()
+
+ qrContent?.let { content ->
+ generateQrCodeBitmap(content)?.let { bitmap ->
+ bitmapsToRelease += bitmap
+ images[PdfImageSet.ImageRef.Qr] = PdfImage(bitmap)
+ }
+ }
+
+ photoFilenames
+ .filter { it.isNotEmpty() }
+ .forEach { filename ->
+ loadPhotoBitmap(filename)?.let { bitmap ->
+ bitmapsToRelease += bitmap
+ images[PdfImageSet.ImageRef.Photo(filename)] = PdfImage(bitmap)
+ }
+ }
+
+ return PdfImageSet(images) { bitmapsToRelease.forEach(Bitmap::recycle) }
+ }
+
+ private fun generateQrCodeBitmap(content: String): Bitmap? =
+ runCatching {
+ generateQrBitmap(
+ content = content,
+ logo =
+ BitmapFactory.decodeResource(context.resources, logoDrawableRes)?.asImageBitmap(),
+ logoSizeFraction = PDF_LOGO_SIZE_FRACTION,
+ )
+ .asAndroidBitmap()
+ }
+ .getOrNull()
+
+ private fun loadPhotoBitmap(remoteFilename: String): Bitmap? {
+ val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null
+ val filename = remoteFilename.substringAfterLast('/')
+ val file = File(rootDir, filename)
+ if (!file.exists()) return null
+ val bitmap =
+ runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull() ?: return null
+ return applyExifOrientation(file, bitmap)
+ }
+
+ /**
+ * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no
+ * rotation is needed.
+ */
+ private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap {
+ val degrees = runCatching { ExifInterface(file.absolutePath).rotationDegrees }.getOrDefault(0)
+ if (degrees == 0) return bitmap
+
+ val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
+ return runCatching {
+ Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+ }
+ .getOrNull()
+ ?.also { if (it != bitmap) bitmap.recycle() } ?: bitmap
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt
new file mode 100644
index 0000000000..e44a3e55e1
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.android.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.image
+
+import android.graphics.Bitmap
+
+/** Android wraps a [Bitmap]. The renderer reads [bitmap] directly to draw on a Canvas. */
+actual class PdfImage(val bitmap: Bitmap) {
+ actual val width: Int
+ get() = bitmap.width
+
+ actual val height: Int
+ get() = bitmap.height
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt
new file mode 100644
index 0000000000..6d242c6adf
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfOutputProvider.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.io
+
+import android.content.Context
+import java.io.File
+
+private const val REPORTS_SUBDIR = "reports"
+
+class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider {
+
+ private val reportsDir
+ get() = File(context.cacheDir, REPORTS_SUBDIR)
+
+ override fun newFilePath(name: String): String {
+ val outputDir = reportsDir.apply { mkdirs() }
+ return File(outputDir, "$name.pdf").absolutePath
+ }
+
+ override fun exists(name: String): Boolean = File(reportsDir, "$name.pdf").exists()
+
+ override fun listFiles(): List =
+ reportsDir
+ .listFiles { f -> f.isFile && f.extension == "pdf" }
+ ?.map { PdfOutputProvider.CachedPdf(it.absolutePath, it.lastModified()) } ?: emptyList()
+
+ override fun deleteReport(path: String) {
+ File(path).delete()
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt
new file mode 100644
index 0000000000..c97998fd17
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/io/AndroidPdfReportLauncher.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.io
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import java.io.File
+
+private const val PDF_MIME_TYPE = "application/pdf"
+
+/** Launches the system share sheet or external viewer for a report file via [FileProvider]. */
+class AndroidPdfReportLauncher(
+ private val context: Context,
+ private val fileProviderAuthority: String,
+) : PdfReportLauncher {
+
+ override fun share(path: String) {
+ val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path))
+ val sendIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = PDF_MIME_TYPE
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ launchChooser(sendIntent)
+ }
+
+ override fun open(path: String) {
+ val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path))
+ val viewIntent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, PDF_MIME_TYPE)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ launchChooser(viewIntent)
+ }
+
+ private fun launchChooser(target: Intent) {
+ val chooser =
+ Intent.createChooser(target, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
+ context.startActivity(chooser)
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt
new file mode 100644
index 0000000000..2e2d29af68
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/util/DateFormatter.android.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.util
+
+import java.text.DateFormat
+import java.util.Date
+import java.util.Locale
+
+actual fun formatDate(millis: Long): String =
+ DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(millis))
+
+actual fun formatTime(millis: Long): String =
+ DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).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..9197c46393 100644
--- a/core/ui/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml
@@ -20,4 +20,6 @@
Compartir
Sitio: %1$s
Recolector de datos: %1$s
+ Escanea este código QR para ver el GeoJSON
+ Otro
diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml
index a02bb387a4..d37a6e916c 100644
--- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml
@@ -19,4 +19,6 @@
Partager
Site: %1$s
Collecteur de données: %1$s
+ Scannez ce code QR pour afficher le GeoJson
+ Autres
diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml
index 7cc6cc9c78..3cdf7c8e70 100644
--- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml
@@ -19,4 +19,6 @@
ແບ່ງປັນ
ຈຸດເກັບຂໍ້ມູນ: %1$s
ຜູ້ເກັບຂໍ້ມູນ: %1$s
+ ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON
+ ອື່ນໆ
diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml
index 3e790c5acf..bac1f70831 100644
--- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml
@@ -20,4 +20,6 @@
Partilhar
Local: %1$s
Coletor de dados: %1$s
+ Leia este código QR para visualizar o GeoJson
+ Outro
diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml
index fb8107eb9e..a86ef18d79 100644
--- a/core/ui/src/commonMain/composeResources/values-th/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml
@@ -19,4 +19,6 @@
แชร์
ไซต์: %1$s
ผู้เก็บข้อมูล: %1$s
+ สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON
+ อื่น ๆ
diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml
index dac00c6a08..fdabc6cb23 100644
--- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml
@@ -19,4 +19,6 @@
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
diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml
index 0176966ea5..1f3695a43f 100644
--- a/core/ui/src/commonMain/composeResources/values/strings.xml
+++ b/core/ui/src/commonMain/composeResources/values/strings.xml
@@ -20,4 +20,17 @@
Share
Site: %1$s
Data collector: %1$s
+ Scan this QR code to view the GeoJson
+ Skipped
+ Other
+ Survey
+ Job
+ Data collector
+ Altitude: %1$sm
+ Accuracy: %1$sm
+ N
+ S
+ E
+ W
+ Submission
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt
index cb4984bac1..e23003ccbc 100644
--- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/ShareButton.kt
@@ -36,7 +36,7 @@ fun ShareButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Icon(
modifier = Modifier.padding(end = 8.dp),
imageVector = vectorResource(Res.drawable.ic_share),
- contentDescription = "Share",
+ contentDescription = stringResource(Res.string.share),
)
Text(stringResource(Res.string.share), modifier = Modifier.padding(4.dp))
}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt
new file mode 100644
index 0000000000..208ca81341
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.components.loireport
+
+sealed interface LoiReportAction {
+ data object OnShareClicked : LoiReportAction
+
+ data object OnPdfItemClicked : LoiReportAction
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt
index 23b076a7c8..6b8c22b321 100644
--- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt
@@ -48,23 +48,6 @@ import org.groundplatform.ui.theme.sizes
@VisibleForTesting const val TEST_TAG_GROUND_QR_CODE = "TEST_TAG_GROUND_QR_CODE"
-/**
- * Maximum content size (in UTF-8 bytes) for which a center logo is displayed.
- *
- * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on
- * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold
- * to ensure the QR code remains reliably scannable even with a logo applied.
- */
-private const val MAX_QR_BYTES_WITH_LOGO = 1000
-
-/**
- * The relative size of the center logo as a fraction of the QR code's total rendered size.
- *
- * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of
- * high error correction (ECC level H), which can tolerate approximately 30% data loss.
- */
-private const val LOGO_SIZE_FRACTION = 0.15f
-
/**
* Displays a QR code generated from the given [content] string.
*
@@ -85,7 +68,7 @@ fun GroundQrCode(
val qrBitmap by
produceState(initialValue = null, key1 = content, key2 = showLogo) {
- value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) }
+ value = withContext(Dispatchers.Default) { encodeQrBitmap(content, showLogo) }
}
Column(
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt
index 529a7e0d19..bc262e9cbe 100644
--- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt
@@ -15,8 +15,67 @@
*/
package org.groundplatform.ui.components.qrcode
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
internal const val QR_SIZE_PX = 512
-expect fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap
+/**
+ * Maximum content size (in UTF-8 bytes) for which a center logo is displayed.
+ *
+ * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on
+ * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold
+ * to ensure the QR code remains reliably scannable even with a logo applied.
+ */
+const val MAX_QR_BYTES_WITH_LOGO = 1000
+
+/**
+ * Default relative size of the center logo as a fraction of the QR code's total size.
+ *
+ * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of
+ * high error correction (ECC level H), which can tolerate approximately 30% data loss.
+ */
+const val LOGO_SIZE_FRACTION = 0.15f
+const val PDF_LOGO_SIZE_FRACTION = 0.22f
+
+/**
+ * Encodes [content] into a bare QR bitmap, using high error correction when [useHighEcc] is set.
+ */
+expect fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap
+
+/**
+ * Generates a QR bitmap for a given [content] with a [logo] in its center. The logo is only applied
+ * when one is supplied and the content size is below [MAX_QR_BYTES_WITH_LOGO] to keep the code
+ * scannable.
+ */
+fun generateQrBitmap(
+ content: String,
+ logo: ImageBitmap?,
+ logoSizeFraction: Float = LOGO_SIZE_FRACTION,
+): ImageBitmap =
+ if (logo == null || content.encodeToByteArray().size > MAX_QR_BYTES_WITH_LOGO) {
+ encodeQrBitmap(content, useHighEcc = false)
+ } else encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction)
+
+/** Draws [logo] centered over the receiver, scaled to [fraction] of its size, into a new bitmap. */
+private fun ImageBitmap.withCenteredLogo(logo: ImageBitmap, fraction: Float): ImageBitmap {
+ val output = ImageBitmap(width, height)
+ val canvas = Canvas(output)
+ val paint = Paint().apply { filterQuality = FilterQuality.High }
+ canvas.drawImage(this, Offset.Zero, paint)
+
+ val logoWidth = (width * fraction).toInt()
+ val logoHeight = (height * fraction).toInt()
+ canvas.drawImageRect(
+ image = logo,
+ dstOffset = IntOffset((width - logoWidth) / 2, (height - logoHeight) / 2),
+ dstSize = IntSize(logoWidth, logoHeight),
+ paint = paint,
+ )
+ return output
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt
new file mode 100644
index 0000000000..87667363c9
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.mapper
+
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.job
+import ground_android.core.ui.generated.resources.pdf_details_data_collector_label
+import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson
+import ground_android.core.ui.generated.resources.submission
+import ground_android.core.ui.generated.resources.survey
+import org.groundplatform.domain.model.locationofinterest.LoiReport
+import org.groundplatform.domain.model.submission.Submission
+import org.groundplatform.domain.model.task.PhotoTaskData
+import org.groundplatform.ui.model.SubmissionPdfDocument
+import org.groundplatform.ui.model.SubmissionPdfDocument.Answer
+import org.groundplatform.ui.model.SubmissionPdfDocument.Footer
+import org.groundplatform.ui.model.SubmissionPdfDocument.Header
+import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock
+import org.groundplatform.ui.model.SubmissionPdfDocument.Row
+import org.groundplatform.ui.system.pdf.PdfExportService
+import org.groundplatform.ui.util.formatDate
+import org.groundplatform.ui.util.formatTime
+import org.jetbrains.compose.resources.getString
+
+object LoiReportMapper {
+
+ suspend fun map(loiReport: LoiReport, submission: Submission): PdfExportService.Request? {
+ val details = loiReport.submissionDetails ?: return null
+ val rows = buildRows(submission)
+ val document =
+ SubmissionPdfDocument(
+ header = buildHeader(details, submission),
+ qrBlock = buildQrBlock(),
+ footer = buildFooter(details),
+ table =
+ SubmissionPdfDocument.Table(
+ submissionLabel = getString(Res.string.submission),
+ loiName = loiReport.loiName,
+ rows = rows,
+ ),
+ )
+ val fileName =
+ listOf(loiReport.loiName, details.userName, details.dateMillis.toString())
+ .joinToString("_") { it.filter(::isSafeFileChar) }
+ .trim('_')
+
+ return PdfExportService.Request(
+ document = document,
+ qrContent = loiReport.geoJson.toString(),
+ fileName = fileName,
+ )
+ }
+
+ private suspend fun buildHeader(
+ details: LoiReport.SubmissionDetails,
+ submission: Submission,
+ ): Header =
+ Header(
+ surveyLabel = getString(Res.string.survey),
+ surveyName = details.surveyName,
+ jobLabel = getString(Res.string.job),
+ jobName = submission.job.name ?: submission.job.id,
+ timestamp = "${formatDate(details.dateMillis)} ${formatTime(details.dateMillis)}",
+ )
+
+ private suspend fun buildQrBlock(): QrBlock =
+ QrBlock(scanCaption = getString(Res.string.scan_this_qr_to_download_geojson))
+
+ private suspend fun buildFooter(details: LoiReport.SubmissionDetails): Footer =
+ Footer(
+ dataCollectorLabel = getString(Res.string.pdf_details_data_collector_label),
+ dataCollectorName = details.userName,
+ userEmail = details.userEmail,
+ )
+
+ private suspend fun buildRows(submission: Submission): List =
+ submission.job.tasksSorted.mapNotNull { task ->
+ if (task.isOmittedFromDocExport()) return@mapNotNull null
+
+ val value = submission.data.getValue(task.id) ?: return@mapNotNull null
+
+ Row(
+ question = task.label,
+ answer =
+ when (value) {
+ is PhotoTaskData -> Answer.Photo(value.remoteFilename)
+ else -> Answer.Text(TaskValueMapper.map(task, value).split("\n"))
+ },
+ )
+ }
+
+ private fun isSafeFileChar(c: Char): Boolean = c.isLetterOrDigit() || c in "_-"
+}
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..3e9bd1fced
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.mapper
+
+import ground_android.core.ui.generated.resources.Res
+import ground_android.core.ui.generated.resources.accuracy
+import ground_android.core.ui.generated.resources.altitude
+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.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.Task
+import org.groundplatform.domain.util.toFixedDecimals
+import org.groundplatform.ui.util.formatDate
+import org.groundplatform.ui.util.formatTime
+import org.jetbrains.compose.resources.getString
+
+object TaskValueMapper {
+ private const val DEGREES_DECIMALS = 6
+
+ /** Renders a [TaskData] value as plain text. */
+ suspend fun map(task: Task, value: TaskData): String =
+ when (value) {
+ is SkippedTaskData -> getString(Res.string.skipped)
+ is TextTaskData -> value.text
+ is NumberTaskData -> value.number
+ is DateTimeTaskData -> formatTaskDateTime(task, value.timeInMillis)
+ is MultipleChoiceTaskData -> formatMultipleChoice(task, value)
+ is CaptureLocationTaskData -> formatCaptureLocation(value)
+ else -> ""
+ }
+
+ /** Date for DATE tasks, time for TIME, date + time otherwise. */
+ private fun formatTaskDateTime(task: Task, millis: Long): String =
+ when (task.type) {
+ Task.Type.DATE -> formatDate(millis)
+ Task.Type.TIME -> formatTime(millis)
+ else -> "${formatDate(millis)} ${formatTime(millis)}"
+ }
+
+ private suspend fun formatMultipleChoice(task: Task, value: MultipleChoiceTaskData): String {
+ val options = task.multipleChoice?.options.orEmpty()
+ val selectedLabels =
+ value.getSelectedOptionsIdsExceptOther().map { id ->
+ options.firstOrNull { it.id == id }?.label ?: id
+ }
+ val withOther =
+ if (value.isOtherTextSelected()) {
+ selectedLabels + "${getString(Res.string.other)}: ${value.getOtherText()}"
+ } else {
+ selectedLabels
+ }
+ return withOther.joinToString("; ")
+ }
+
+ private suspend fun formatCaptureLocation(value: CaptureLocationTaskData): String {
+ val lines = mutableListOf(formatPoint(value.location))
+ value.altitude?.let { lines.add(getString(Res.string.altitude, formatMeters(it))) }
+ value.accuracy?.let { lines.add(getString(Res.string.accuracy, formatMeters(it))) }
+ return lines.joinToString("\n")
+ }
+
+ private suspend fun formatPoint(point: Point): String {
+ val lat = point.coordinates.lat
+ val lng = point.coordinates.lng
+ val latDir = if (lat >= 0) getString(Res.string.north) else getString(Res.string.south)
+ val lngDir = if (lng >= 0) getString(Res.string.east) else getString(Res.string.west)
+ return "${formatDegrees(lat)} $latDir, ${formatDegrees(lng)} $lngDir"
+ }
+
+ private fun formatDegrees(value: Double): String =
+ "${value.absoluteValue.toFixedDecimals(DEGREES_DECIMALS)}°"
+
+ private fun formatMeters(value: Double): String = round(value).toLong().toString()
+}
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/system/pdf/PdfConfig.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt
new file mode 100644
index 0000000000..595eec69aa
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfConfig.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+/**
+ * Dimensional and type-scale constants shared by the Android and iOS PDF renderers. Keeping these
+ * in commonMain prevents the two platforms from drifting on page size, margins, or type scale.
+ *
+ * All measurements are in PDF points (1/72 inch); A4 is 595 × 842.
+ */
+object PdfConfig {
+ const val PAGE_WIDTH = 595
+ const val PAGE_HEIGHT = 842
+ const val MARGIN = 40
+ const val HEADER_SIZE = 11f
+ const val BODY_SIZE = 11f
+ const val CAPTION_SIZE = 9f
+ const val LINE_SPACING = 4f
+ const val QR_SIZE = 200
+ const val HEADER_COLUMN_GAP = 16
+ const val TABLE_QUESTION_RATIO = 0.35f
+ const val CELL_PADDING = 6
+ const val BORDER_WIDTH = 0.5f
+ const val PHOTO_MAX_HEIGHT = 360
+ const val HEADER_BOTTOM_GAP = 28f
+ const val FOOTER_TOP_GAP = 28f
+ const val MAX_HEADER_VALUE_LINES = 2
+ const val MAX_FOOTER_LINES = 2
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt
new file mode 100644
index 0000000000..1983b96e19
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import org.groundplatform.ui.model.SubmissionPdfDocument
+import org.groundplatform.ui.model.SubmissionPdfDocument.Answer
+import org.groundplatform.ui.system.pdf.image.PdfImageProvider
+import org.groundplatform.ui.system.pdf.io.PdfOutputProvider
+import org.groundplatform.ui.system.pdf.io.PdfReportLauncher
+
+/**
+ * Shared entry point for the PDF export flow.
+ *
+ * Loads images, renders the document to disk, then opens or shares the file using
+ * [PdfReportLauncher].
+ */
+class PdfExportService(
+ private val imageProvider: PdfImageProvider,
+ private val renderer: PdfRenderer,
+ private val outputProvider: PdfOutputProvider,
+ private val launcher: PdfReportLauncher,
+ private val coroutineDispatcher: CoroutineDispatcher,
+) {
+ suspend fun export(request: Request, action: Action) {
+ val outputPath =
+ withContext(coroutineDispatcher) {
+ outputProvider.pruneOldFiles()
+ val path = outputProvider.newFilePath(request.fileName)
+ if (!outputProvider.exists(request.fileName)) {
+ val images = imageProvider.load(request.qrContent, request.document.photoFilenames())
+ try {
+ renderer.render(request.document, images, path)
+ } finally {
+ images.release()
+ }
+ }
+ path
+ }
+ when (action) {
+ Action.Open -> launcher.open(outputPath)
+ Action.Share -> launcher.share(outputPath)
+ }
+ }
+
+ enum class Action {
+ Open,
+ Share,
+ }
+
+ data class Request(
+ val document: SubmissionPdfDocument,
+ val qrContent: String?,
+ val fileName: String,
+ )
+}
+
+/** The distinct, non-empty photo filenames referenced by the document's table rows. */
+private fun SubmissionPdfDocument.photoFilenames(): Set =
+ table.rows
+ .mapNotNull { (it.answer as? Answer.Photo)?.remoteFilename }
+ .filter { it.isNotEmpty() }
+ .toSet()
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt
new file mode 100644
index 0000000000..952da3152a
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf
+
+import org.groundplatform.ui.model.SubmissionPdfDocument
+import org.groundplatform.ui.system.pdf.image.PdfImageSet
+
+/**
+ * Rasterises a [SubmissionPdfDocument] to a PDF file. Each platform should use its native text
+ * layout and PDF APIs to handle wrapping, pagination, and drawing. Writes the result to the
+ * provided output path.
+ */
+interface PdfRenderer {
+ suspend fun render(document: SubmissionPdfDocument, images: PdfImageSet, outputPath: String)
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt
new file mode 100644
index 0000000000..0128e85bca
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.image
+
+/**
+ * Opaque platform image handle (Android `Bitmap`, iOS `CGImageRef`). The renderer reads the
+ * platform value via the `actual` declaration.
+ */
+expect class PdfImage {
+ val width: Int
+ val height: Int
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.kt
new file mode 100644
index 0000000000..7327708764
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageProvider.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.system.pdf.image
+
+/**
+ * Platform abstraction for images (QR code and photos) needed for PDF rendering. Implementations
+ * should handle bitmap decoding and resource lifecycle management.
+ */
+interface PdfImageProvider {
+ suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt
new file mode 100644
index 0000000000..335c559e63
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImageSet.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.image
+
+/**
+ * The set of images needed to render a single report. Owns the lifecycle of the platform handles,
+ * call [release] in a `finally` block to free native resources.
+ */
+class PdfImageSet(
+ private val images: Map,
+ private val onRelease: () -> Unit = {},
+) {
+ operator fun get(ref: ImageRef): PdfImage? = images[ref]
+
+ fun release() {
+ onRelease()
+ }
+
+ sealed interface ImageRef {
+ data object Qr : ImageRef
+
+ data class Photo(val filename: String) : ImageRef
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt
new file mode 100644
index 0000000000..bdcb48f842
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfOutputProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.io
+
+import kotlin.time.Clock
+
+private const val MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1000 // 1 week
+
+/** Manages file paths and cached PDF reports. */
+interface PdfOutputProvider {
+ fun newFilePath(name: String): String
+
+ fun exists(name: String): Boolean
+
+ fun listFiles(): List
+
+ fun deleteReport(path: String)
+
+ /** Removes cached reports older than [MAX_AGE_MILLIS] (1 week). */
+ fun pruneOldFiles() {
+ val now = Clock.System.now().toEpochMilliseconds()
+ listFiles()
+ .filter { now - it.lastModifiedMillis > MAX_AGE_MILLIS }
+ .forEach { deleteReport(it.path) }
+ }
+
+ /** A cached PDF file entry with its path and last-modified timestamp. */
+ data class CachedPdf(val path: String, val lastModifiedMillis: Long)
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt
new file mode 100644
index 0000000000..ee25f0138e
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/io/PdfReportLauncher.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.io
+
+/**
+ * Presents the two terminal actions on a generated report: share via system share sheet, open in an
+ * external viewer.
+ */
+interface PdfReportLauncher {
+ fun share(path: String)
+
+ fun open(path: String)
+}
diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt
new file mode 100644
index 0000000000..23900757b8
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/util/DateFormatter.kt
@@ -0,0 +1,27 @@
+/*
+ * 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/time formatting primitives. Implemented per-platform because locale-aware
+ * formatting needs `java.text.DateFormat` on Android and `NSDateFormatter` on iOS.
+ */
+
+/** Formats just the date portion of [millis] in the user's locale. */
+expect fun formatDate(millis: Long): String
+
+/** Formats just the time portion of [millis] in the user's locale (short style, no seconds). */
+expect fun formatTime(millis: Long): String
diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt
index 80c2139c79..58ea1067de 100644
--- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt
+++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt
@@ -56,7 +56,7 @@ private const val INPUT_CORRECTION_LEVEL_KEY = "inputCorrectionLevel"
private val ciContext: CIContext = CIContext.contextWithOptions(null)
-actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap {
+actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap {
val ciImage = createQrCIImage(content, useHighEcc)
val scaled = scaleToTargetSize(ciImage)
return scaled.toComposeImageBitmap()
diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt
new file mode 100644
index 0000000000..a90c65581c
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/image/PdfImage.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.system.pdf.image
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.CoreGraphics.CGImageGetHeight
+import platform.CoreGraphics.CGImageGetWidth
+import platform.CoreGraphics.CGImageRef
+
+@OptIn(ExperimentalForeignApi::class)
+actual class PdfImage(val cgImage: CGImageRef) {
+ actual val width: Int
+ get() = CGImageGetWidth(cgImage).toInt()
+
+ actual val height: Int
+ get() = CGImageGetHeight(cgImage).toInt()
+}
diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt
new file mode 100644
index 0000000000..3b8e56f208
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/util/DateFormatter.ios.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.ui.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
+
+@OptIn(ExperimentalForeignApi::class)
+actual fun formatDate(millis: Long): String =
+ NSDateFormatter()
+ .apply {
+ dateStyle = NSDateFormatterMediumStyle
+ timeStyle = NSDateFormatterNoStyle
+ }
+ .stringFromDate(NSDate.dateWithTimeIntervalSince1970(millis / 1000.0))
+
+@OptIn(ExperimentalForeignApi::class)
+actual fun formatTime(millis: Long): String =
+ NSDateFormatter()
+ .apply {
+ dateStyle = NSDateFormatterNoStyle
+ timeStyle = NSDateFormatterShortStyle
+ }
+ .stringFromDate(NSDate.dateWithTimeIntervalSince1970(millis / 1000.0))
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a815097933..55779f39d3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -30,6 +30,7 @@ coreTestingVersion = "1.1.1"
coreVersion = "1.7.0"
coroutinesVersion = "1.11.0"
detektVersion = "1.23.8"
+exifInterfaceVersion = "1.4.2"
espressoContribVersion = "3.7.0"
firebaseBomVersion = "34.13.0"
firebaseCrashlyticsGradleVersion = "3.0.7"
@@ -90,6 +91,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4
androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" }
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
+androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifInterfaceVersion" }
androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" }
androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoContribVersion" }