diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index 201ac97b22..abba86236e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -18,6 +18,7 @@ package org.groundplatform.android.ui.datacollection import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -112,6 +113,13 @@ internal constructor( private var draftsEnabled = true + /** + * Set while the final submission is being saved. The state stays [DataCollectionUiState.Ready] + * until the save completes, so without this, repeated taps on "Done" each save the same data + * again, creating duplicate LOIs and submissions. + */ + private val isSubmitting = AtomicBoolean(false) + init { viewModelScope.launch { val initResult = @@ -296,22 +304,35 @@ internal constructor( if (!taskSequenceHandler.isLastPosition(task.id)) { moveToNextTask() } else { - clearDraft() - externalScope.launch(ioDispatcher) { - val submittedLoiId = saveChanges(uiState, getDeltas()) - val loiReport = - getLoiReportUseCase.invoke( - loiName = getTypedLoiNameOrEmpty(), - loiId = submittedLoiId, - surveyId = uiState.surveyId, - ) - _uiState.value = DataCollectionUiState.TaskSubmitted(loiReport) - } + submit(uiState) } } } } + /** Saves the collected data, ignoring the call if a save is already in progress. */ + private fun submit(state: DataCollectionUiState.Ready) { + if (!isSubmitting.compareAndSet(false, true)) return + clearDraft() + externalScope.launch(ioDispatcher) { + val submittedLoiId = + try { + saveChanges(state, getDeltas()) + } catch (e: Throwable) { + // Nothing was saved, so let the user try again. + isSubmitting.set(false) + throw e + } + val loiReport = + getLoiReportUseCase.invoke( + loiName = getTypedLoiNameOrEmpty(), + loiId = submittedLoiId, + surveyId = state.surveyId, + ) + _uiState.value = DataCollectionUiState.TaskSubmitted(loiReport) + } + } + fun onPreviousClicked(taskId: String) { val taskViewModel = getTaskViewModel(taskId) ?: return withReady { _ -> diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt index ddaf073b3b..6a470df8af 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModelTest.kt @@ -19,18 +19,27 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData +import org.groundplatform.android.data.uuid.OfflineUuidGenerator +import org.groundplatform.android.ui.common.ViewModelFactory +import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel import org.groundplatform.domain.model.task.Task +import org.groundplatform.domain.usecases.submission.SubmitDataUseCase import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -104,7 +113,53 @@ class DataCollectionViewModelTest : BaseHiltTest() { assertThat(viewModel.showExitWarning.value).isFalse() } - private suspend fun setupViewModel(result: DataCollectionUiState): DataCollectionViewModel { + @Test + fun `onNextClicked on last task submits only once while the save is in progress`() = + runWithTestDispatcher { + val saveBlocker = CompletableDeferred() + val submitDataUseCase = + mock().apply { + whenever(invoke(anyOrNull(), any(), any(), any(), anyOrNull(), any())) + .doSuspendableAnswer { saveBlocker.await() } + } + val taskViewModel = + mock().apply { + whenever(task) doReturn READY_STATE.tasks.first() + whenever(taskTaskData) doReturn MutableStateFlow(null) + whenever(validate()) doReturn null + } + val viewModelFactory = + mock().apply { + whenever(create(any>())) doReturn taskViewModel + } + val offlineUuidGenerator = + mock().apply { whenever(generateUuid()) doReturn "collection-id" } + val viewModel = + setupViewModel( + READY_STATE, + submitDataUseCase = submitDataUseCase, + viewModelFactory = viewModelFactory, + offlineUuidGenerator = offlineUuidGenerator, + ) + advanceUntilIdle() + + repeat(3) { viewModel.onNextClicked("taskId") } + advanceUntilIdle() + saveBlocker.complete("loi-id") + advanceUntilIdle() + + verify(submitDataUseCase, times(1)) + .invoke(anyOrNull(), any(), any(), any(), anyOrNull(), any()) + assertThat(viewModel.uiState.value) + .isInstanceOf(DataCollectionUiState.TaskSubmitted::class.java) + } + + private suspend fun setupViewModel( + result: DataCollectionUiState, + submitDataUseCase: SubmitDataUseCase = mock(), + viewModelFactory: ViewModelFactory = mock(), + offlineUuidGenerator: OfflineUuidGenerator = mock(), + ): DataCollectionViewModel { val initializer = mock().apply { whenever(initialize(any(), any(), anyOrNull(), anyOrNull())) doReturn @@ -116,9 +171,9 @@ class DataCollectionViewModelTest : BaseHiltTest() { externalScope = CoroutineScope(testDispatcher), ioDispatcher = testDispatcher, submissionRepository = mock(), - submitDataUseCase = mock(), - offlineUuidGenerator = mock(), - viewModelFactory = mock(), + submitDataUseCase = submitDataUseCase, + offlineUuidGenerator = offlineUuidGenerator, + viewModelFactory = viewModelFactory, dataCollectionInitializer = initializer, getLoiReportUseCase = mock(), loiReportExporter = mock(),