diff --git a/app/src/main/java/org/groundplatform/android/model/SurveyExtensions.kt b/app/src/main/java/org/groundplatform/android/model/SurveyExtensions.kt new file mode 100644 index 0000000000..b0f1d39f29 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/model/SurveyExtensions.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 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.model + +import org.groundplatform.android.model.job.Job + +/** + * Checks if a survey is usable for data collection. A survey is considered usable if it has at + * least one predefined LOI or at least one job that allows ad hoc LOIs. + */ +fun Survey.isUsable(loiCount: Int = 0): Boolean { + // If there are predefined LOIs, the survey is usable + if (loiCount > 0) { + return true + } + + // If there's at least one job that allows ad hoc LOIs, the survey is usable + return jobs.any { job -> + job.strategy == Job.DataCollectionStrategy.AD_HOC || + job.strategy == Job.DataCollectionStrategy.MIXED + } +} diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index 9cc33076a4..be300aa400 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -162,6 +162,9 @@ constructor( */ suspend fun hasValidLois(surveyId: String): Boolean = localLoiStore.getLoiCount(surveyId) > 0 + /** Returns the count of valid (not deleted) [LocationOfInterest] for the given [surveyId]. */ + suspend fun getLoiCount(surveyId: String): Int = localLoiStore.getLoiCount(surveyId) + /** Returns a flow of all valid (not deleted) [LocationOfInterest] in the given [Survey]. */ fun getValidLois(survey: Survey): Flow> = localLoiStore.getValidLois(survey) diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt index f0cf0b2a4e..7dab218d99 100644 --- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt @@ -23,6 +23,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import org.groundplatform.android.R import org.groundplatform.android.databinding.SurveySelectorFragBinding import org.groundplatform.android.model.SurveyListItem import org.groundplatform.android.ui.common.AbstractFragment @@ -72,6 +73,10 @@ class SurveySelectorFragment : AbstractFragment(), BackPressListener { dismissProgressDialog() ephemeralPopups.ErrorPopup().unknownError() } + is UiState.UnusableSurvey -> { + dismissProgressDialog() + ephemeralPopups.ErrorPopup().show(R.string.unusable_survey_error) + } is UiState.NavigateToHome -> { findNavController().navigate(HomeScreenFragmentDirections.showHomeScreen()) } diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt index be02814b8c..88426a7fb6 100644 --- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt @@ -36,6 +36,7 @@ import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase import org.groundplatform.android.usecases.survey.ListAvailableSurveysUseCase import org.groundplatform.android.usecases.survey.RemoveOfflineSurveyUseCase +import org.groundplatform.android.usecases.survey.UnusableSurveyException import timber.log.Timber /** Represents view state and behaviors of the survey selector dialog. */ @@ -106,7 +107,13 @@ internal constructor( onSurveyActivationFailed() } }, - onFailure = { onSurveyActivationFailed(it) }, + onFailure = { error -> + if (error is UnusableSurveyException) { + onUnusableSurvey() + } else { + onSurveyActivationFailed(error) + } + }, ) } } @@ -117,6 +124,12 @@ internal constructor( _uiState.emit(UiState.NavigateToHome) } + private suspend fun onUnusableSurvey() { + Timber.e("Survey is unusable: no predefined LOIs and no ad hoc jobs") + surveyActivationInProgress = false + _uiState.emit(UiState.UnusableSurvey) + } + private suspend fun onSurveyActivationFailed(error: Throwable? = null) { Timber.e(error, "Failed to activate survey") surveyActivationInProgress = false diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/UiState.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/UiState.kt index 41efad12a0..3af3785e8b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/UiState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/UiState.kt @@ -40,5 +40,8 @@ sealed class UiState { /** Represents that there was an error while activating surveys. */ data object Error : UiState() + /** Represents that the selected survey has no predefined LOIs and no ad hoc jobs. */ + data object UnusableSurvey : UiState() + data object NavigateToHome : UiState() } diff --git a/app/src/main/java/org/groundplatform/android/usecases/survey/ActivateSurveyUseCase.kt b/app/src/main/java/org/groundplatform/android/usecases/survey/ActivateSurveyUseCase.kt index bb73b5718e..6ed62205a8 100644 --- a/app/src/main/java/org/groundplatform/android/usecases/survey/ActivateSurveyUseCase.kt +++ b/app/src/main/java/org/groundplatform/android/usecases/survey/ActivateSurveyUseCase.kt @@ -18,6 +18,8 @@ package org.groundplatform.android.usecases.survey import javax.inject.Inject import org.groundplatform.android.data.sync.SurveySyncWorker +import org.groundplatform.android.model.isUsable +import org.groundplatform.android.repository.LocationOfInterestRepository import org.groundplatform.android.repository.SurveyRepository /** @@ -31,12 +33,14 @@ import org.groundplatform.android.repository.SurveyRepository class ActivateSurveyUseCase @Inject constructor( + private val locationOfInterestRepository: LocationOfInterestRepository, private val makeSurveyAvailableOffline: MakeSurveyAvailableOfflineUseCase, private val surveyRepository: SurveyRepository, ) { /** * @return `true` if the survey was successfully activated or was already active, otherwise false. + * @throws UnusableSurveyException if the survey has no predefined LOIs and no ad hoc jobs. */ suspend operator fun invoke(surveyId: String): Boolean { if (surveyRepository.isSurveyActive(surveyId)) { @@ -44,12 +48,22 @@ constructor( return true } - surveyRepository.getOfflineSurvey(surveyId) - ?: makeSurveyAvailableOffline(surveyId) - ?: error("Survey $surveyId not found in remote db") + val survey = + surveyRepository.getOfflineSurvey(surveyId) + ?: makeSurveyAvailableOffline(surveyId) + ?: error("Survey $surveyId not found in remote db") + + // Check if the survey has predefined LOIs or ad hoc jobs + val loiCount = locationOfInterestRepository.getLoiCount(surveyId) + if (!survey.isUsable(loiCount)) { + throw UnusableSurveyException("Survey $surveyId has no predefined LOIs and no ad hoc jobs") + } surveyRepository.activateSurvey(surveyId) return surveyRepository.isSurveyActive(surveyId) } } + +/** Exception thrown when a survey has no predefined LOIs and no ad hoc jobs. */ +class UnusableSurveyException(message: String) : Exception(message) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fa2fd8630..0e7d41c1f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,11 @@ This field is required + + This survey cannot be used because it has no predefined locations and does not allow adding new locations. + diff --git a/app/src/test/java/org/groundplatform/android/model/SurveyExtensionsTest.kt b/app/src/test/java/org/groundplatform/android/model/SurveyExtensionsTest.kt new file mode 100644 index 0000000000..917a0c05a3 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/model/SurveyExtensionsTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2025 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.model + +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.model.job.Job +import org.groundplatform.android.proto.Survey +import org.junit.Test + +class SurveyExtensionsTest { + + @Test + fun `isUsable returns true when survey has predefined LOIs`() { + val survey = createSurvey(emptyMap()) + + // Test with predefined LOIs + assertThat(survey.isUsable(loiCount = 5)).isTrue() + } + + @Test + fun `isUsable returns true when survey has ad hoc jobs`() { + val jobWithAdHoc = + Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.AD_HOC) + + val survey = createSurvey(mapOf("job1" to jobWithAdHoc)) + + // Test with no LOIs but with ad hoc job + assertThat(survey.isUsable(loiCount = 0)).isTrue() + } + + @Test + fun `isUsable returns true when survey has mixed jobs`() { + val jobWithMixed = Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.MIXED) + + val survey = createSurvey(mapOf("job1" to jobWithMixed)) + + // Test with no LOIs but with mixed job + assertThat(survey.isUsable(loiCount = 0)).isTrue() + } + + @Test + fun `isUsable returns false when survey has no predefined LOIs and only predefined jobs`() { + val jobWithPredefined = + Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.PREDEFINED) + + val survey = createSurvey(mapOf("job1" to jobWithPredefined)) + + // Test with no LOIs and only predefined job + assertThat(survey.isUsable(loiCount = 0)).isFalse() + } + + @Test + fun `isUsable returns false when survey has no predefined LOIs and no jobs`() { + val survey = createSurvey(emptyMap()) + + // Test with no LOIs and no jobs + assertThat(survey.isUsable(loiCount = 0)).isFalse() + } + + private fun createSurvey(jobMap: Map) = + Survey( + id = "survey1", + title = "Test Survey", + description = "Test Description", + jobMap = jobMap, + generalAccess = Survey.GeneralAccess.RESTRICTED, + ) +}