diff --git a/ground/src/main/java/com/google/android/ground/model/submission/DraftSubmission.kt b/ground/src/main/java/com/google/android/ground/model/submission/DraftSubmission.kt new file mode 100644 index 0000000000..72fb95de5f --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/model/submission/DraftSubmission.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 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 com.google.android.ground.model.submission + +/** Represents a single instance of data being collected by the user. */ +data class DraftSubmission( + val id: String, + val jobId: String, + val loiId: String?, + val surveyId: String, + val deltas: List, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt b/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt index 68eb382b71..62fbd3a83c 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt @@ -47,6 +47,11 @@ abstract class LocalDataStoreModule { @Binds @Singleton abstract fun userStore(store: RoomUserStore): LocalUserStore companion object { + @Provides + fun draftSubmissionDao(localDatabase: LocalDatabase): DraftSubmissionDao { + return localDatabase.draftSubmissionDao() + } + @Provides fun locationOfInterestDao(localDatabase: LocalDatabase): LocationOfInterestDao { return localDatabase.locationOfInterestDao() diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/LocalValueStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/LocalValueStore.kt index bfdb438cfb..9028ea816d 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/LocalValueStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/LocalValueStore.kt @@ -92,6 +92,12 @@ class LocalValueStore @Inject constructor(private val preferences: SharedPrefere preferences.edit().putBoolean(DRAW_AREA_INSTRUCTIONS_SHOWN, value).apply() } + var draftSubmissionId: String? + get() = allowThreadDiskReads { preferences.getString(DRAFT_SUBMISSION_ID, null) } + set(value) = allowThreadDiskReads { + preferences.edit().putString(DRAFT_SUBMISSION_ID, value).apply() + } + /** Removes all values stored in the local store. */ fun clear() = allowThreadDiskWrites { preferences.edit().clear().apply() } @@ -128,5 +134,6 @@ class LocalValueStore @Inject constructor(private val preferences: SharedPrefere const val LOCATION_LOCK_ENABLED = "location_lock_enabled" const val OFFLINE_MAP_IMAGERY = "offline_map_imagery" const val DRAW_AREA_INSTRUCTIONS_SHOWN = "draw_area_instructions_shown" + const val DRAFT_SUBMISSION_ID = "draft_submission_id" } } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt index e2dc2969e9..1209c3533c 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt @@ -25,6 +25,7 @@ import com.google.android.ground.persistence.local.room.converter.JsonObjectType import com.google.android.ground.persistence.local.room.converter.LoiPropertiesMapConverter import com.google.android.ground.persistence.local.room.converter.StyleTypeConverter import com.google.android.ground.persistence.local.room.dao.ConditionDao +import com.google.android.ground.persistence.local.room.dao.DraftSubmissionDao import com.google.android.ground.persistence.local.room.dao.ExpressionDao import com.google.android.ground.persistence.local.room.dao.JobDao import com.google.android.ground.persistence.local.room.dao.LocationOfInterestDao @@ -39,6 +40,7 @@ import com.google.android.ground.persistence.local.room.dao.TaskDao import com.google.android.ground.persistence.local.room.dao.TileSourceDao import com.google.android.ground.persistence.local.room.dao.UserDao import com.google.android.ground.persistence.local.room.entity.ConditionEntity +import com.google.android.ground.persistence.local.room.entity.DraftSubmissionEntity import com.google.android.ground.persistence.local.room.entity.ExpressionEntity import com.google.android.ground.persistence.local.room.entity.JobEntity import com.google.android.ground.persistence.local.room.entity.LocationOfInterestEntity @@ -72,6 +74,7 @@ import com.google.android.ground.persistence.local.room.fields.TileSetEntityStat @Database( entities = [ + DraftSubmissionEntity::class, LocationOfInterestEntity::class, LocationOfInterestMutationEntity::class, TaskEntity::class, @@ -107,6 +110,8 @@ import com.google.android.ground.persistence.local.room.fields.TileSetEntityStat LoiPropertiesMapConverter::class, ) abstract class LocalDatabase : RoomDatabase() { + abstract fun draftSubmissionDao(): DraftSubmissionDao + abstract fun locationOfInterestDao(): LocationOfInterestDao abstract fun locationOfInterestMutationDao(): LocationOfInterestMutationDao diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt index 31362f5e8a..b0cc0cb9b3 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt @@ -27,6 +27,7 @@ import com.google.android.ground.model.job.Style import com.google.android.ground.model.locationofinterest.LocationOfInterest import com.google.android.ground.model.mutation.LocationOfInterestMutation import com.google.android.ground.model.mutation.SubmissionMutation +import com.google.android.ground.model.submission.DraftSubmission import com.google.android.ground.model.submission.Submission import com.google.android.ground.model.submission.SubmissionData import com.google.android.ground.model.task.Condition @@ -488,3 +489,27 @@ fun ExpressionEntity.toModelObject(): Expression = taskId = taskId, optionIds = optionIds?.split(',')?.toSet() ?: setOf(), ) + +@Throws(LocalDataConsistencyException::class) +fun DraftSubmissionEntity.toModelObject(survey: Survey): DraftSubmission { + val job = + survey.getJob(jobId) + ?: throw LocalDataConsistencyException("Unknown jobId in submission mutation $id") + + return DraftSubmission( + id = id, + jobId = jobId, + loiId = loiId, + surveyId = surveyId, + deltas = SubmissionDeltasConverter.fromString(job, deltas), + ) +} + +fun DraftSubmission.toLocalDataStoreObject() = + DraftSubmissionEntity( + id = id, + jobId = jobId, + loiId = loiId, + surveyId = surveyId, + deltas = SubmissionDeltasConverter.toString(deltas), + ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/DraftSubmissionDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/DraftSubmissionDao.kt new file mode 100644 index 0000000000..03a286d8cf --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/DraftSubmissionDao.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 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 com.google.android.ground.persistence.local.room.dao + +import androidx.room.Dao +import androidx.room.Query +import com.google.android.ground.persistence.local.room.entity.DraftSubmissionEntity + +/** Data access object for database operations related to [DraftSubmissionDao]. */ +@Dao +interface DraftSubmissionDao : BaseDao { + + @Query("SELECT * FROM draft_submission WHERE id = :draftSubmissionId") + suspend fun findById(draftSubmissionId: String): DraftSubmissionEntity? + + @Query("DELETE FROM draft_submission") fun delete() +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/DraftSubmissionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/DraftSubmissionEntity.kt new file mode 100644 index 0000000000..16b0f0f9e8 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/DraftSubmissionEntity.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 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 com.google.android.ground.persistence.local.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.android.ground.model.submission.DraftSubmission + +/** Representation of a [DraftSubmission] in local db. */ +@Entity(tableName = "draft_submission", indices = [Index("loi_id", "job_id", "survey_id")]) +data class DraftSubmissionEntity( + @ColumnInfo(name = "id") @PrimaryKey val id: String, + @ColumnInfo(name = "job_id") val jobId: String, + @ColumnInfo(name = "loi_id") val loiId: String?, + @ColumnInfo(name = "survey_id") val surveyId: String, + @ColumnInfo(name = "deltas") val deltas: String?, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSubmissionStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSubmissionStore.kt index e763d010bd..9b009480cd 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSubmissionStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSubmissionStore.kt @@ -22,6 +22,7 @@ import com.google.android.ground.model.job.Job import com.google.android.ground.model.locationofinterest.LocationOfInterest import com.google.android.ground.model.mutation.Mutation import com.google.android.ground.model.mutation.SubmissionMutation +import com.google.android.ground.model.submission.DraftSubmission import com.google.android.ground.model.submission.Submission import com.google.android.ground.model.submission.SubmissionData import com.google.android.ground.model.submission.ValueDelta @@ -30,6 +31,7 @@ import com.google.android.ground.persistence.local.room.converter.SubmissionData import com.google.android.ground.persistence.local.room.converter.SubmissionDeltasConverter import com.google.android.ground.persistence.local.room.converter.toLocalDataStoreObject import com.google.android.ground.persistence.local.room.converter.toModelObject +import com.google.android.ground.persistence.local.room.dao.DraftSubmissionDao import com.google.android.ground.persistence.local.room.dao.SubmissionDao import com.google.android.ground.persistence.local.room.dao.SubmissionMutationDao import com.google.android.ground.persistence.local.room.dao.insertOrUpdate @@ -53,6 +55,7 @@ import timber.log.Timber /** Manages access to [Submission] objects persisted in local storage. */ @Singleton class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore { + @Inject lateinit var draftSubmissionDao: DraftSubmissionDao @Inject lateinit var submissionDao: SubmissionDao @Inject lateinit var submissionMutationDao: SubmissionMutationDao @Inject lateinit var userStore: RoomUserStore @@ -64,7 +67,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore */ override suspend fun getSubmission( locationOfInterest: LocationOfInterest, - submissionId: String + submissionId: String, ): Submission = submissionDao.findById(submissionId)?.toModelObject(locationOfInterest) ?: throw LocalDataStoreException("Submission not found $submissionId") @@ -76,7 +79,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore */ override suspend fun getSubmissions( locationOfInterest: LocationOfInterest, - jobId: String + jobId: String, ): List = submissionDao .findByLocationOfInterestId(locationOfInterest.id, jobId, EntityState.DEFAULT) @@ -87,7 +90,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore .findBySubmissionId( model.id, MutationEntitySyncStatus.PENDING, - MutationEntitySyncStatus.IN_PROGRESS + MutationEntitySyncStatus.IN_PROGRESS, ) ?.let { mergeSubmission(model.job, model.toLocalDataStoreObject(), it) } } @@ -145,7 +148,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore private suspend fun mergeSubmission( job: Job, submission: SubmissionEntity, - mutations: List + mutations: List, ) { if (mutations.isEmpty()) { submissionDao.insertOrUpdate(submission) @@ -160,7 +163,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore job: Job?, submission: SubmissionEntity, mutations: List, - user: User + user: User, ): SubmissionEntity { val lastMutation = mutations[mutations.size - 1] val clientTimestamp = lastMutation.clientTimestamp @@ -169,14 +172,14 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore return submission.copy( data = SubmissionDataConverter.toString(commitMutations(job, submission, mutations)), - lastModified = AuditInfoEntity(UserDetails.fromUser(user), clientTimestamp) + lastModified = AuditInfoEntity(UserDetails.fromUser(user), clientTimestamp), ) } private fun commitMutations( job: Job?, submission: SubmissionEntity, - mutations: List + mutations: List, ): SubmissionData { val responseMap = SubmissionDataConverter.fromString(job!!, submission.data) val deltas = mutableListOf() @@ -194,7 +197,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore override fun getSubmissionMutationsByLoiIdFlow( survey: Survey, locationOfInterestId: String, - vararg allowedStates: MutationEntitySyncStatus + vararg allowedStates: MutationEntitySyncStatus, ): Flow> = submissionMutationDao.findByLoiIdFlow(locationOfInterestId, *allowedStates).map { list: List -> @@ -220,7 +223,7 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore override suspend fun findByLocationOfInterestId( loidId: String, - vararg states: MutationEntitySyncStatus + vararg states: MutationEntitySyncStatus, ): List = submissionMutationDao.findByLocationOfInterestId(loidId, *states) @@ -228,13 +231,26 @@ class RoomSubmissionStore @Inject internal constructor() : LocalSubmissionStore submissionMutationDao.getSubmissionMutationCount( loiId, MutationEntityType.CREATE, - MutationEntitySyncStatus.PENDING + MutationEntitySyncStatus.PENDING, ) override suspend fun getPendingDeleteCount(loiId: String): Int = submissionMutationDao.getSubmissionMutationCount( loiId, MutationEntityType.DELETE, - MutationEntitySyncStatus.PENDING + MutationEntitySyncStatus.PENDING, ) + + override suspend fun getDraftSubmission( + draftSubmissionId: String, + survey: Survey, + ): DraftSubmission? = draftSubmissionDao.findById(draftSubmissionId)?.toModelObject(survey) + + override suspend fun saveDraftSubmission(draftSubmission: DraftSubmission) { + draftSubmissionDao.insertOrUpdate(draftSubmission.toLocalDataStoreObject()) + } + + override suspend fun deleteDraftSubmissions() { + draftSubmissionDao.delete() + } } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalSubmissionStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalSubmissionStore.kt index 60168131d1..ed4a5d8028 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalSubmissionStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalSubmissionStore.kt @@ -18,6 +18,7 @@ package com.google.android.ground.persistence.local.stores import com.google.android.ground.model.Survey import com.google.android.ground.model.locationofinterest.LocationOfInterest import com.google.android.ground.model.mutation.SubmissionMutation +import com.google.android.ground.model.submission.DraftSubmission import com.google.android.ground.model.submission.Submission import com.google.android.ground.persistence.local.room.entity.SubmissionMutationEntity import com.google.android.ground.persistence.local.room.fields.MutationEntitySyncStatus @@ -30,13 +31,13 @@ interface LocalSubmissionStore : LocalMutationStore /** Returns the submission with the specified UUID from the local data store, if found. */ suspend fun getSubmission( locationOfInterest: LocationOfInterest, - submissionId: String + submissionId: String, ): Submission /** Deletes submission from local database. */ @@ -49,7 +50,7 @@ interface LocalSubmissionStore : LocalMutationStore> /** @@ -60,10 +61,19 @@ interface LocalSubmissionStore : LocalMutationStore suspend fun getPendingCreateCount(loiId: String): Int suspend fun getPendingDeleteCount(loiId: String): Int + + /** Fetches the draft submission for the given UUID from local database. */ + suspend fun getDraftSubmission(draftSubmissionId: String, survey: Survey): DraftSubmission? + + /** Saves the given draft submission to local database. */ + suspend fun saveDraftSubmission(draftSubmission: DraftSubmission) + + /** Removes all locally stored draft submissions. */ + suspend fun deleteDraftSubmissions() } diff --git a/ground/src/main/java/com/google/android/ground/repository/SubmissionRepository.kt b/ground/src/main/java/com/google/android/ground/repository/SubmissionRepository.kt index c2081dedf1..d2a09937cb 100644 --- a/ground/src/main/java/com/google/android/ground/repository/SubmissionRepository.kt +++ b/ground/src/main/java/com/google/android/ground/repository/SubmissionRepository.kt @@ -16,12 +16,15 @@ package com.google.android.ground.repository import com.google.android.ground.model.AuditInfo +import com.google.android.ground.model.Survey import com.google.android.ground.model.locationofinterest.LocationOfInterest import com.google.android.ground.model.mutation.Mutation import com.google.android.ground.model.mutation.Mutation.SyncStatus import com.google.android.ground.model.mutation.SubmissionMutation +import com.google.android.ground.model.submission.DraftSubmission import com.google.android.ground.model.submission.Submission import com.google.android.ground.model.submission.ValueDelta +import com.google.android.ground.persistence.local.LocalValueStore import com.google.android.ground.persistence.local.stores.LocalSubmissionStore import com.google.android.ground.persistence.sync.MutationSyncWorkManager import com.google.android.ground.persistence.uuid.OfflineUuidGenerator @@ -38,10 +41,11 @@ class SubmissionRepository @Inject constructor( private val localSubmissionStore: LocalSubmissionStore, + private val localValueStore: LocalValueStore, private val locationOfInterestRepository: LocationOfInterestRepository, private val mutationSyncWorkManager: MutationSyncWorkManager, private val userRepository: UserRepository, - private val uuidGenerator: OfflineUuidGenerator + private val uuidGenerator: OfflineUuidGenerator, ) { suspend fun createSubmission(surveyId: String, locationOfInterestId: String): Submission { @@ -53,7 +57,7 @@ constructor( private suspend fun createOrUpdateSubmission( submission: Submission, deltas: List, - isNew: Boolean + isNew: Boolean, ) = applyAndEnqueue( SubmissionMutation( @@ -64,19 +68,39 @@ constructor( syncStatus = SyncStatus.PENDING, surveyId = submission.surveyId, locationOfInterestId = submission.locationOfInterest.id, - userId = submission.lastModified.user.id + userId = submission.lastModified.user.id, ) ) suspend fun saveSubmission( surveyId: String, locationOfInterestId: String, - deltas: List + deltas: List, ) { val submission = createSubmission(surveyId, locationOfInterestId) createOrUpdateSubmission(submission, deltas, isNew = true) } + suspend fun getDraftSubmission(draftSubmissionId: String, survey: Survey): DraftSubmission? = + localSubmissionStore.getDraftSubmission(draftSubmissionId = draftSubmissionId, survey = survey) + + suspend fun saveDraftSubmission( + jobId: String, + loiId: String?, + surveyId: String, + deltas: List, + ) { + val newId = uuidGenerator.generateUuid() + val draft = DraftSubmission(newId, jobId, loiId, surveyId, deltas) + localSubmissionStore.saveDraftSubmission(draftSubmission = draft) + localValueStore.draftSubmissionId = newId + } + + suspend fun deleteDraftSubmission() { + localSubmissionStore.deleteDraftSubmissions() + localValueStore.draftSubmissionId = null + } + private suspend fun applyAndEnqueue(mutation: SubmissionMutation) { localSubmissionStore.applyAndEnqueue(mutation) mutationSyncWorkManager.enqueueSyncWorker(mutation.locationOfInterestId) diff --git a/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt b/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt index 9c6bf6e1f9..236f548133 100644 --- a/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt +++ b/ground/src/main/java/com/google/android/ground/ui/common/EphemeralPopups.kt @@ -61,24 +61,29 @@ class EphemeralPopups @Inject constructor(private val context: Application) { } /** Defines functions to render a popup that displays an informational message to the user. */ - inner class InfoPopup { - fun show( - view: View, - @StringRes messageId: Int, - duration: PopupDuration = PopupDuration.INDEFINITE, - ) { - val msg = context.resources.getString(messageId) - showSnackbar(view, msg, duration) - } + inner class InfoPopup(view: View, @StringRes messageId: Int, duration: PopupDuration) { + private var snackbar: Snackbar = + Snackbar.make(view, context.resources.getString(messageId), durationToSnackDuration(duration)) + .setAction(context.getString(R.string.dismiss_info_popup)) { this.dismiss() } + val isShown: Boolean + get() = this.snackbar.isShown - private fun showSnackbar(view: View, msg: String, duration: PopupDuration) { - val dur = - when (duration) { - PopupDuration.SHORT -> Snackbar.LENGTH_SHORT - PopupDuration.LONG -> Snackbar.LENGTH_LONG - PopupDuration.INDEFINITE -> Snackbar.LENGTH_INDEFINITE - } - Snackbar.make(view, msg, dur).show() + val view: View + get() = this.snackbar.view + + fun show() = snackbar.show() + + fun setAnchor(view: View) = snackbar.setAnchorView(view) + + private fun dismiss() { + this.snackbar.dismiss() } + + private fun durationToSnackDuration(duration: PopupDuration) = + when (duration) { + PopupDuration.SHORT -> Snackbar.LENGTH_SHORT + PopupDuration.LONG -> Snackbar.LENGTH_LONG + PopupDuration.INDEFINITE -> Snackbar.LENGTH_INDEFINITE + } } } diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt index e3a54224a7..c38da1cd9f 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt @@ -64,6 +64,12 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { progressBar = binding.progressBar guideline = binding.progressBarGuideline getAbstractActivity().setSupportActionBar(binding.dataCollectionToolbar) + + binding.dataCollectionToolbar.setNavigationOnClickListener { + viewModel.clearDraft() + navigator.navigateUp() + } + return binding.root } @@ -141,6 +147,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { if (viewPager.currentItem == 0) { // If the user is currently looking at the first step, allow the system to handle the // Back button. This calls finish() on this activity and pops the back stack. + viewModel.clearDraft() false } else { // Otherwise, select the previous step. diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt index 6bec14ebef..09d3bde062 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt @@ -26,7 +26,9 @@ import com.google.android.ground.model.submission.Value import com.google.android.ground.model.submission.ValueDelta import com.google.android.ground.model.task.Condition import com.google.android.ground.model.task.Task +import com.google.android.ground.persistence.local.room.converter.SubmissionDeltasConverter import com.google.android.ground.repository.LocationOfInterestRepository +import com.google.android.ground.repository.SubmissionRepository import com.google.android.ground.repository.SurveyRepository import com.google.android.ground.ui.common.AbstractViewModel import com.google.android.ground.ui.common.EphemeralPopups @@ -75,20 +77,22 @@ internal constructor( @ApplicationScope private val externalScope: CoroutineScope, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, + private val submissionRepository: SubmissionRepository, locationOfInterestRepository: LocationOfInterestRepository, surveyRepository: SurveyRepository, ) : AbstractViewModel() { - private val jobId: String? = savedStateHandle[TASK_JOB_ID_KEY] + private val jobId: String = requireNotNull(savedStateHandle[TASK_JOB_ID_KEY]) private val loiId: String? = savedStateHandle[TASK_LOI_ID_KEY] /** True iff the user is expected to produce a new LOI in the current data collection flow. */ private val isAddLoiFlow = loiId == null - private val activeSurvey: Survey = requireNotNull(surveyRepository.activeSurvey) - private val job: Job = - activeSurvey.getJob(requireNotNull(jobId)) ?: error("couldn't retrieve job for $jobId") + private var shouldLoadFromDraft: Boolean = savedStateHandle[TASK_SHOULD_LOAD_FROM_DRAFT] ?: false + private var draftDeltas: List? = null + private val activeSurvey: Survey = requireNotNull(surveyRepository.activeSurvey) + private val job: Job = activeSurvey.getJob(jobId) ?: error("couldn't retrieve job for $jobId") // LOI creation task is included only on "new data collection site" flow.. val tasks: List = if (isAddLoiFlow) job.tasksSorted else job.tasksSorted.filterNot { it.isAddLoiTask } @@ -118,6 +122,31 @@ internal constructor( lateinit var submissionId: String + private fun getDraftDeltas(): List { + if (!shouldLoadFromDraft) return listOf() + if (draftDeltas != null) return draftDeltas as List + + val serializedDraftValues = savedStateHandle[TASK_DRAFT_VALUES] ?: "" + if (serializedDraftValues.isEmpty()) { + Timber.e("Attempting load from draft submission failed, not found") + return listOf() + } + + draftDeltas = SubmissionDeltasConverter.fromString(job, serializedDraftValues) + return draftDeltas as List + } + + private fun getValueFromDraft(task: Task): Value? { + for ((taskId, taskType, value) in getDraftDeltas()) { + if (taskId == task.id && taskType == task.type) { + Timber.d("Value $value found for task $task") + return value + } + } + Timber.w("Value not found for task $task") + return null + } + fun getTaskViewModel(position: Int): AbstractTaskViewModel? { val viewModels = taskViewModels.value @@ -127,8 +156,8 @@ internal constructor( } return try { val viewModel = viewModelFactory.create(getViewModelClass(task.type)) - // TODO(#1146): Pass in the existing value if there is one. - viewModel.initialize(job, task, null) + val value: Value? = if (shouldLoadFromDraft) getValueFromDraft(task) else null + viewModel.initialize(job, task, value) addTaskViewModel(viewModel) viewModel } catch (e: Exception) { @@ -146,7 +175,7 @@ internal constructor( * Validates the user's input and displays an error if the user input was invalid. Moves back to * the previous Data Collection screen if the user input was valid. */ - fun onPreviousClicked(taskViewModel: AbstractTaskViewModel) { + suspend fun onPreviousClicked(taskViewModel: AbstractTaskViewModel) { check(getPositionInTaskSequence().first != 0) val validationError = taskViewModel.validate() @@ -154,6 +183,9 @@ internal constructor( popups.get().ErrorPopup().show(validationError) return } + + data[taskViewModel.task] = taskViewModel.taskValue.firstOrNull() + step(-1) } @@ -173,8 +205,8 @@ internal constructor( if (!isLastPosition()) { step(1) } else { - val deltas = data.map { (task, value) -> ValueDelta(task.id, task.type, value) } - saveChanges(deltas) + clearDraft() + saveChanges(getDeltas()) // Move to home screen and display a confirmation dialog after that. navigator.navigate(HomeScreenFragmentDirections.showHomeScreen()) @@ -185,6 +217,9 @@ internal constructor( } } + private fun getDeltas(): List = + data.map { (task, value) -> ValueDelta(task.id, task.type, value) } + /** Persists the changes locally and enqueues a worker to sync with remote datastore. */ private fun saveChanges(deltas: List) { externalScope.launch(ioDispatcher) { submitDataUseCase.invoke(loiId, job, surveyId, deltas) } @@ -196,6 +231,22 @@ internal constructor( } return tasks.indexOf(tasks.first { it.id == currentTaskId.value }) } + /** Persists the collected data as draft to local storage. */ + private fun saveDraft() { + externalScope.launch(ioDispatcher) { + submissionRepository.saveDraftSubmission( + jobId = jobId, + loiId = loiId, + surveyId = surveyId, + deltas = getDeltas(), + ) + } + } + + /** Clears all persisted drafts from local storage. */ + fun clearDraft() { + externalScope.launch(ioDispatcher) { submissionRepository.deleteDraftSubmission() } + } /** * Get the current index within the computed task sequence, and the number of tasks in the @@ -238,6 +289,10 @@ internal constructor( .take(Math.abs(stepCount) + 1) .last() savedStateHandle[TASK_POSITION_ID] = task.id + + // Save collected data as draft + clearDraft() + saveDraft() } /** Returns true if the given task index is last if set, or the current active task. */ @@ -258,6 +313,8 @@ internal constructor( private const val TASK_JOB_ID_KEY = "jobId" private const val TASK_LOI_ID_KEY = "locationOfInterestId" private const val TASK_POSITION_ID = "currentTaskId" + private const val TASK_SHOULD_LOAD_FROM_DRAFT = "shouldLoadFromDraft" + private const val TASK_DRAFT_VALUES = "draftValues" fun getViewModelClass(taskType: Task.Type): Class = when (taskType) { diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/AbstractTaskFragment.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/AbstractTaskFragment.kt index bdfa41b2fe..f318b61e92 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -26,6 +26,7 @@ import com.google.android.ground.R import com.google.android.ground.model.submission.Value import com.google.android.ground.model.submission.isNotNullOrEmpty import com.google.android.ground.model.submission.isNullOrEmpty +import com.google.android.ground.model.task.Task import com.google.android.ground.ui.common.AbstractFragment import com.google.android.ground.ui.datacollection.DataCollectionViewModel import com.google.android.ground.ui.datacollection.components.ButtonAction @@ -154,7 +155,7 @@ abstract class AbstractTaskFragment : AbstractFragmen } private fun moveToPrevious() { - dataCollectionViewModel.onPreviousClicked(viewModel) + lifecycleScope.launch { dataCollectionViewModel.onPreviousClicked(viewModel) } } fun moveToNext() { @@ -188,6 +189,10 @@ abstract class AbstractTaskFragment : AbstractFragmen private fun ButtonAction.shouldReplaceWithDoneButton() = this == ButtonAction.NEXT && dataCollectionViewModel.isLastPosition(position) + fun getTask(): Task = viewModel.task + + fun getCurrentValue(): Value? = viewModel.taskValue.value + @TestOnly fun getButtons() = buttons @TestOnly fun getButtonsIndex() = buttonsIndex diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt index 0d35899edc..ca2a9cf971 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt @@ -19,7 +19,9 @@ import android.view.LayoutInflater import android.view.View import androidx.recyclerview.widget.RecyclerView import com.google.android.ground.databinding.MultipleChoiceTaskFragBinding +import com.google.android.ground.model.submission.MultipleChoiceResponse import com.google.android.ground.model.task.MultipleChoice +import com.google.android.ground.model.task.Option import com.google.android.ground.ui.datacollection.components.TaskView import com.google.android.ground.ui.datacollection.components.TaskViewFactory import com.google.android.ground.ui.datacollection.tasks.AbstractTaskFragment @@ -43,14 +45,36 @@ class MultipleChoiceTaskFragment : AbstractTaskFragment, + isMultipleChoice: Boolean, + selectedIndices: List, + updateResponse: (options: List