Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,27 @@ constructor(
} ?: run { Timber.w("Job not found for survey $surveyId and LOI $locationOfInterestId") }
}

override suspend fun getDraftSubmission(
draftSubmissionId: String,
override suspend fun getDraftSubmission(survey: Survey): DraftSubmission? {
val draftId = localValueStore.draftSubmissionId
val draft =
if (draftId.isNullOrEmpty()) null
else localSubmissionStore.getDraftSubmission(draftSubmissionId = draftId, survey = survey)
if (draft != null && draft.surveyId != survey.id) {
Timber.e("Skipping draft submission, survey id doesn't match")
return null
}
return draft
}

override suspend fun getDraftSubmissionForSession(
survey: Survey,
jobId: String,
loiId: String?,
): DraftSubmission? =
localSubmissionStore.getDraftSubmission(draftSubmissionId = draftSubmissionId, survey = survey)
getDraftSubmission(survey)?.takeIf { it.jobId == jobId && it.loiId == loiId }

override suspend fun countDraftSubmissions() = localSubmissionStore.countDraftSubmissions()

override fun getDraftSubmissionsId() = localValueStore.draftSubmissionId ?: ""

override suspend fun saveDraftSubmission(
jobId: String,
loiId: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,18 @@ import org.groundplatform.android.ui.common.LocationOfInterestHelper
import org.groundplatform.android.ui.datacollection.DataCollectionInitializer.Companion.TASK_POSITION_ID
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.job.Job
import org.groundplatform.domain.model.submission.TaskData
import org.groundplatform.domain.model.submission.ValueDelta
import org.groundplatform.domain.model.task.Task
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
import org.groundplatform.domain.repository.SubmissionRepositoryInterface
import org.groundplatform.domain.repository.SurveyRepositoryInterface

data class DataCollectionInitializerResult(
val uiState: DataCollectionUiState,
val collectedData: Map<Task, TaskData> = emptyMap(),
)

/**
* DataCollectionInitializer
*
Expand All @@ -38,7 +46,8 @@ import org.groundplatform.domain.repository.SurveyRepositoryInterface
* - Load active survey (with timeout).
* - Resolve job by id.
* - Pick displayable tasks (exclude Add-LOI when LOI exists).
* - Choose initial task id from SavedStateHandle (if valid) or first task.
* - Restore the data collected so far from the draft of an interrupted session, if any.
* - Choose initial task id from SavedStateHandle or the draft (if valid), or first task.
* - Compute a simple [TaskPosition] from list order.
* - Resolve a user-visible LOI name (typed for Add-LOI, formatted for existing LOI).
*/
Expand All @@ -47,31 +56,33 @@ class DataCollectionInitializer
constructor(
private val locationOfInterestHelper: LocationOfInterestHelper,
private val locationOfInterestRepository: LocationOfInterestRepositoryInterface,
private val submissionRepository: SubmissionRepositoryInterface,
private val surveyRepository: SurveyRepositoryInterface,
) {

/**
* Computes the initial [DataCollectionUiState] without building any sequence.
* Computes the initial [DataCollectionInitializerResult] without building any sequence.
*
* Reads from [savedStateHandle]:
* - [TASK_POSITION_ID]: previously visited task id (optional).
* - [TASK_LOI_NAME_KEY]: user-typed LOI name for Add-LOI flows (optional).
*/
suspend fun initialize(
savedStateHandle: SavedStateHandle,
jobId: String,
loiId: String?,
loiName: String?,
): DataCollectionUiState =
): DataCollectionInitializerResult =
try {
val survey = loadSurveyOrThrow()
val job = resolveJobOrThrow(survey, jobId)
val tasks = pickTasks(job, loiId)
if (tasks.isEmpty()) throw DataCollectionException.NoValidTasks

val draft = submissionRepository.getDraftSubmissionForSession(survey, jobId, loiId)

val savedTaskId: String? = savedStateHandle[TASK_POSITION_ID]
val currentTaskId =
resolveInitialTaskId(tasks, savedTaskId)
resolveInitialTaskId(tasks, savedTaskId, draft?.currentTaskId)
?: throw DataCollectionException.Wrapped(
DataCollectionErrorCode.INITIAL_TASK_RESOLUTION_FAILED,
IllegalStateException("No valid initial task id"),
Expand All @@ -89,21 +100,24 @@ constructor(

val loiName = computeLoiName(survey.id, loiId, loiName)

DataCollectionUiState.Ready(
surveyId = survey.id,
job = job,
loiName = loiName,
tasks = tasks,
isAddLoiFlow = loiId == null,
currentTaskId = currentTaskId,
position = position,
DataCollectionInitializerResult(
DataCollectionUiState.Ready(
surveyId = survey.id,
job = job,
loiName = loiName,
tasks = tasks,
isAddLoiFlow = loiId == null,
currentTaskId = currentTaskId,
position = position,
),
collectedData = restoreData(tasks, draft?.deltas.orEmpty()),
)
} catch (e: DataCollectionException) {
DataCollectionUiState.Error(e.code, e)
DataCollectionInitializerResult(DataCollectionUiState.Error(e.code, e))
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
DataCollectionUiState.Error(mapThrowableToCode(t), t)
DataCollectionInitializerResult(DataCollectionUiState.Error(mapThrowableToCode(t), t))
}

private suspend fun loadSurveyOrThrow(): Survey =
Expand All @@ -122,14 +136,21 @@ constructor(
private fun pickTasks(job: Job, loiId: String?): List<Task> =
if (loiId == null) job.tasksSorted else job.tasksSorted.filterNot { it.isAddLoiTask }

private fun restoreData(tasks: List<Task>, deltas: List<ValueDelta>): Map<Task, TaskData> {
val deltaMap = deltas.associateBy { it.taskId to it.taskType }
return tasks
.mapNotNull { task -> deltaMap[task.id to task.type]?.newTaskData?.let { task to it } }
.toMap()
}

/**
* Choose initial task:
* - Use [saved] if it exists within [tasks].
* - Use the first of [candidates] which exists within [tasks].
* - Otherwise, first task in list.
*/
private fun resolveInitialTaskId(tasks: List<Task>, saved: String?): String? {
private fun resolveInitialTaskId(tasks: List<Task>, vararg candidates: String?): String? {
val validIds = tasks.map { it.id }.toSet()
return saved?.takeIf { it in validIds } ?: tasks.firstOrNull()?.id
return candidates.filterNotNull().firstOrNull { it in validIds } ?: tasks.firstOrNull()?.id
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter
import org.groundplatform.android.data.uuid.OfflineUuidGenerator
import org.groundplatform.android.di.coroutines.ApplicationScope
import org.groundplatform.android.di.coroutines.IoDispatcher
Expand All @@ -51,7 +50,6 @@ import org.groundplatform.android.ui.datacollection.tasks.point.DropPinTaskViewM
import org.groundplatform.android.ui.datacollection.tasks.polygon.DrawAreaTaskViewModel
import org.groundplatform.android.ui.datacollection.tasks.text.TextTaskViewModel
import org.groundplatform.android.ui.datacollection.tasks.time.TimeTaskViewModel
import org.groundplatform.domain.model.job.Job
import org.groundplatform.domain.model.submission.TaskData
import org.groundplatform.domain.model.submission.ValueDelta
import org.groundplatform.domain.model.submission.isNotNullOrEmpty
Expand Down Expand Up @@ -108,8 +106,6 @@ internal constructor(
private val _loiNameDraft = MutableStateFlow("")
val loiNameDraft: StateFlow<String> = _loiNameDraft

private var shouldLoadFromDraft: Boolean = savedStateHandle[TASK_SHOULD_LOAD_FROM_DRAFT] ?: false

private val jobId: String = requireNotNull(savedStateHandle[TASK_JOB_ID_KEY])
private val loiId: String? = savedStateHandle[TASK_LOI_ID_KEY]

Expand All @@ -129,20 +125,32 @@ internal constructor(
getTypedLoiNameOrEmpty(),
)

if (initResult is DataCollectionUiState.Ready) {
if (shouldLoadFromDraft) {
initializeDraftValues(initResult.job, initResult.tasks)
_uiState.value =
when (val state = initResult.uiState) {
is DataCollectionUiState.Ready -> {
taskDataHandler.setData(initResult.collectedData)
taskSequenceHandler = TaskSequenceHandler(state.tasks, taskDataHandler)
setupSession(state)
}
is DataCollectionUiState.Error -> {
Timber.e(state.cause, "Initialization failed code=%s", state.code)
state
}
else -> {
state
}
}
taskSequenceHandler = TaskSequenceHandler(initResult.tasks, taskDataHandler)
}

if (initResult is DataCollectionUiState.Error) {
Timber.e(initResult.cause, "Initialization failed code=%s", initResult.code)
}
_uiState.value = initResult
}
}

private fun setupSession(state: DataCollectionUiState.Ready): DataCollectionUiState.Ready {
val taskId = taskSequenceHandler.getResumeTask(state.currentTaskId)
if (taskId == state.currentTaskId) return state

Timber.w("No data restored for task %s; resuming at %s", state.currentTaskId, taskId)
return state.withTask(taskId)
}

private fun setLoiName(name: String) {
savedStateHandle[TASK_LOI_NAME_KEY] = name
_uiState.update { state ->
Expand Down Expand Up @@ -410,11 +418,9 @@ internal constructor(
val validIds = taskSequenceHandler.getValidTasks().map { it.id }.toSet()
val safeId = if (taskId in validIds) taskId else validIds.first()

savedStateHandle[TASK_POSITION_ID] = safeId
val newState = st.withTask(safeId)
saveDraft(safeId)

val newPos = taskSequenceHandler.getTaskPosition(safeId)
_uiState.value = st.copy(currentTaskId = safeId, position = newPos)
_uiState.value = newState
}

private fun getDeltas(): List<ValueDelta> {
Expand Down Expand Up @@ -464,33 +470,6 @@ internal constructor(
return block(s)
}

private fun initializeDraftValues(job: Job, tasks: List<Task>) {
val serialized: String? = savedStateHandle[TASK_DRAFT_VALUES]
if (serialized.isNullOrBlank()) {
Timber.w("No draft values found; skipping load")
return
}

val parsed =
try {
SubmissionDeltasConverter.fromString(job, serialized)
} catch (e: Exception) {
Timber.e(e, "Failed to parse draft submission")
emptyList()
}

val deltaMap = parsed.associateBy { it.taskId to it.taskType }

val draftValues =
tasks
.mapNotNull { task -> deltaMap[task.id to task.type]?.newTaskData?.let { task to it } }
.toMap()

if (draftValues.isNotEmpty()) {
taskDataHandler.setData(draftValues)
}
}

private inline fun validateOrShow(taskVm: AbstractTaskViewModel, onValid: () -> Unit) {
val error = taskVm.validate()
if (error != null) {
Expand All @@ -505,13 +484,16 @@ internal constructor(
.map { (it as? DataCollectionUiState.Ready)?.currentTaskId == taskId }
.distinctUntilChanged()

private fun DataCollectionUiState.Ready.withTask(taskId: String): DataCollectionUiState.Ready {
savedStateHandle[TASK_POSITION_ID] = taskId
return copy(currentTaskId = taskId, position = taskSequenceHandler.getTaskPosition(taskId))
}

companion object {
private const val TASK_JOB_ID_KEY = "jobId"
private const val TASK_LOI_ID_KEY = "locationOfInterestId"
private const val TASK_LOI_NAME_KEY = "locationOfInterestName"
private const val TASK_POSITION_ID = "currentTaskId"
private const val TASK_DRAFT_VALUES = "draftValues"
private const val TASK_SHOULD_LOAD_FROM_DRAFT = "shouldLoadFromDraft"

fun getViewModelClass(taskType: Task.Type): Class<out AbstractTaskViewModel> =
when (taskType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ class TaskSequenceHandler(
return validTasks
}

/**
* Returns the task ID to resume. Ensures sequences never skip past tasks with required answers,
* so incomplete answers get recollected instead of omitted.
*/
fun getResumeTask(taskId: String): String {
validateTaskId(taskId)
val sequence = getValidTasks()
val target = sequence.firstOrNull { it.id == taskId } ?: sequence.first()
val unanswered =
sequence
.takeWhile { it != target }
.firstOrNull { (it.isRequired || it.isAddLoiTask) && taskDataHandler.getData(it) == null }
return (unanswered ?: target).id
}

/** Resets the local cache of the task list. */
fun invalidateCache() {
isTaskListReady = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter
import org.groundplatform.android.databinding.HomeScreenFragBinding
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.BackPressListener
Expand Down Expand Up @@ -152,9 +151,6 @@ class HomeScreenFragment : AbstractFragment(), BackPressListener {
draft.loiId,
draft.loiName ?: "",
draft.jobId,
true,
SubmissionDeltasConverter.toString(draft.deltas),
draft.currentTaskId ?: "",
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import org.groundplatform.domain.repository.OfflineAreaRepositoryInterface
import org.groundplatform.domain.repository.SubmissionRepositoryInterface
import org.groundplatform.domain.repository.SurveyRepositoryInterface
import org.groundplatform.domain.repository.UserRepositoryInterface
import timber.log.Timber

data class HomeDrawerState(val user: User, val survey: Survey?, val appVersion: String)

Expand Down Expand Up @@ -129,24 +128,10 @@ internal constructor(

/** Attempts to return draft submission for the currently active active survey. */
suspend fun getDraftSubmission(): DraftSubmission? {
val draftId = submissionRepository.getDraftSubmissionsId()
val survey = surveyRepository.activeSurveyFlow.first()

if (survey == null || draftId.isEmpty()) {
// No active survey or draft submission.
return null
}

val draft = submissionRepository.getDraftSubmission(draftId, survey) ?: return null

if (draft.surveyId != survey.id) {
Timber.e("Skipping draft submission, survey id doesn't match")
return null
}

// TODO: Check whether the previous user id matches with current user or not.
// Issue URL: https://github.com/google/ground-android/issues/2903
return draft
val survey = surveyRepository.activeSurveyFlow.first() ?: return null
return submissionRepository.getDraftSubmission(survey)
}

fun openNavDrawer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
cardUiData.loi.id,
cardUiData.loi.properties[LOI_NAME_PROPERTY] as? String?,
cardUiData.loi.job.id,
false,
null,
"",
)
)
is AdHocDataCollectionButtonData ->
Expand All @@ -204,9 +201,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() {
null,
null,
cardUiData.job.id,
false,
null,
"",
)
)
}
Expand Down
Loading
Loading