From b70c871f491f27b64ec9a77d400161a9d046216e Mon Sep 17 00:00:00 2001 From: sufyanAbbasi Date: Tue, 5 Mar 2024 12:08:37 -0800 Subject: [PATCH] Support conditional branching for task progression (#2255) * Define a Condition and Expression class which can compute conditional branching on tasks. * Refactor to use the current task ID instead of position to compute the next and previous tasks. * Pass selected task options into the condition evaluator - Note, need to remove logs before finalizing. * Cleanup condition logging. * Add Condition and Expression local database tables and local support. - Also formatting etc. * Small style fix. * Update progress bar to scale with conditional tasks. * Log unknown MatchType/ExpressionType and formatting * Refactor to use TaskID --> TaskValue map instead. * Use extension functions, revert copyright years. * Formatting and clarity fixes. * Copyright year fix. * Code formatting. * Code formatting try 3 --------- Co-authored-by: Sufyan Abbasi Co-authored-by: Gino Miceli <228050+gino-m@users.noreply.github.com> Co-authored-by: Shobhit Agarwal --- .../java/com/google/android/ground/Config.kt | 2 +- .../android/ground/model/task/Condition.kt | 87 +++++++ .../google/android/ground/model/task/Task.kt | 3 +- .../persistence/local/LocalDataStoreModule.kt | 10 + .../persistence/local/room/LocalDatabase.kt | 50 +++- .../local/room/converter/ConverterExt.kt | 51 +++- .../local/room/dao/ConditionDao.kt | 21 ++ .../local/room/dao/ExpressionDao.kt | 21 ++ .../local/room/entity/ConditionEntity.kt | 41 ++++ .../local/room/entity/ExpressionEntity.kt | 44 ++++ .../local/room/entity/TaskEntity.kt | 8 +- .../local/room/fields/ExpressionEntityType.kt | 55 +++++ .../local/room/fields/MatchEntityType.kt | 55 +++++ .../relations/ConditionEntityAndRelations.kt | 37 +++ .../room/relations/TaskEntityAndRelations.kt | 8 +- .../local/room/stores/RoomSurveyStore.kt | 22 ++ .../firebase/schema/ConditionConverter.kt | 94 +++++++ .../firebase/schema/ConditionNestedObject.kt | 34 +++ .../remote/firebase/schema/TaskConverter.kt | 4 +- .../firebase/schema/TaskNestedObject.kt | 3 +- .../datacollection/DataCollectionFragment.kt | 18 +- .../datacollection/DataCollectionViewModel.kt | 93 +++++-- .../tasks/AbstractTaskFragment.kt | 4 +- .../ground/model/task/ConditionTest.kt | 229 ++++++++++++++++++ .../firebase/schema/ConditionConverterTest.kt | 178 ++++++++++++++ 25 files changed, 1127 insertions(+), 45 deletions(-) create mode 100644 ground/src/main/java/com/google/android/ground/model/task/Condition.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionNestedObject.kt create mode 100644 ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt create mode 100644 ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverterTest.kt diff --git a/ground/src/main/java/com/google/android/ground/Config.kt b/ground/src/main/java/com/google/android/ground/Config.kt index 28ad6845bc..a8c7fa5025 100644 --- a/ground/src/main/java/com/google/android/ground/Config.kt +++ b/ground/src/main/java/com/google/android/ground/Config.kt @@ -26,7 +26,7 @@ object Config { // Local db settings. // TODO(#128): Reset version to 1 before releasing. - const val DB_VERSION = 113 + const val DB_VERSION = 114 const val DB_NAME = "ground.db" // Firebase Cloud Firestore settings. diff --git a/ground/src/main/java/com/google/android/ground/model/task/Condition.kt b/ground/src/main/java/com/google/android/ground/model/task/Condition.kt new file mode 100644 index 0000000000..5950a40267 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/model/task/Condition.kt @@ -0,0 +1,87 @@ +/* + * 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.task + +import com.google.android.ground.model.submission.MultipleChoiceResponse +import com.google.android.ground.model.submission.Value + +/** The task ID. */ +typealias TaskId = String +/** The selected values keyed by task ID. */ +typealias TaskSelections = Map + +/** + * Describes a user-defined condition on a task, which determines whether the given task should be + * hidden due to failure of fulfillment based on the input expressions. + */ +data class Condition( + /** Determines the evaluation condition for fulfillment (e.g. all or some expressions). */ + val matchType: MatchType = MatchType.UNKNOWN, + /** The expressions to evaluate to fulfill the condition. */ + val expressions: List = listOf(), +) { + + /** Match type names as they appear in the remote database. */ + enum class MatchType { + UNKNOWN, + MATCH_ANY, + MATCH_ALL, + MATCH_ONE, + } + + /** Given the user's task selections, determine whether the condition is fulfilled. */ + fun fulfilledBy(taskSelections: TaskSelections) = + when (matchType) { + MatchType.MATCH_ANY -> expressions.any { it.fulfilledBy(taskSelections) } + MatchType.MATCH_ALL -> expressions.all { it.fulfilledBy(taskSelections) } + MatchType.MATCH_ONE -> expressions.filter { it.fulfilledBy(taskSelections) }.size == 1 + MatchType.UNKNOWN -> throw IllegalArgumentException("Unknown match type: $matchType") + } +} + +data class Expression( + /** Determines the evaluation condition for the expression (e.g. all or some selected options). */ + val expressionType: ExpressionType = ExpressionType.UNKNOWN, + /** The task ID associated with this expression. */ + val taskId: String, + /** The option IDs that need to be selected to fulfill the condition. */ + val optionIds: Set = setOf(), +) { + + /** Task type names as they appear in the remote database. */ + enum class ExpressionType { + UNKNOWN, + ANY_OF_SELECTED, + ALL_OF_SELECTED, + ONE_OF_SELECTED, + } + + /** Given the selected options for this task, determine whether the expression is fulfilled. */ + fun fulfilledBy(taskSelections: TaskSelections): Boolean = + taskSelections[this.taskId]?.let { selection -> this.fulfilled(selection) } ?: false + + private fun fulfilled(value: Value): Boolean { + if (value !is MultipleChoiceResponse) return false + val selectedOptions = value.selectedOptionIds.toSet() + return when (expressionType) { + ExpressionType.ANY_OF_SELECTED -> optionIds.any { it in selectedOptions } + ExpressionType.ALL_OF_SELECTED -> selectedOptions.containsAll(optionIds) + ExpressionType.ONE_OF_SELECTED -> selectedOptions.intersect(optionIds).size == 1 + ExpressionType.UNKNOWN -> + throw IllegalArgumentException("Unknown expression type: $expressionType") + } + } +} diff --git a/ground/src/main/java/com/google/android/ground/model/task/Task.kt b/ground/src/main/java/com/google/android/ground/model/task/Task.kt index 98d8cfd0c5..da6f1f3e8f 100644 --- a/ground/src/main/java/com/google/android/ground/model/task/Task.kt +++ b/ground/src/main/java/com/google/android/ground/model/task/Task.kt @@ -31,7 +31,8 @@ constructor( val label: String, val isRequired: Boolean, val multipleChoice: MultipleChoice? = null, - val isAddLoiTask: Boolean = false + val isAddLoiTask: Boolean = false, + val condition: Condition? = null, ) { /** 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 9ad15d0e45..68eb382b71 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 @@ -106,5 +106,15 @@ abstract class LocalDataStoreModule { fun userDao(localDatabase: LocalDatabase): UserDao { return localDatabase.userDao() } + + @Provides + fun conditionDao(localDatabase: LocalDatabase): ConditionDao { + return localDatabase.conditionDao() + } + + @Provides + fun expressionDao(localDatabase: LocalDatabase): ExpressionDao { + return localDatabase.expressionDao() + } } } 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 1064ad358b..e2dc2969e9 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 @@ -24,9 +24,43 @@ import com.google.android.ground.persistence.local.room.converter.JsonArrayTypeC import com.google.android.ground.persistence.local.room.converter.JsonObjectTypeConverter 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.* -import com.google.android.ground.persistence.local.room.entity.* -import com.google.android.ground.persistence.local.room.fields.* +import com.google.android.ground.persistence.local.room.dao.ConditionDao +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 +import com.google.android.ground.persistence.local.room.dao.LocationOfInterestMutationDao +import com.google.android.ground.persistence.local.room.dao.MultipleChoiceDao +import com.google.android.ground.persistence.local.room.dao.OfflineAreaDao +import com.google.android.ground.persistence.local.room.dao.OptionDao +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.SurveyDao +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.ExpressionEntity +import com.google.android.ground.persistence.local.room.entity.JobEntity +import com.google.android.ground.persistence.local.room.entity.LocationOfInterestEntity +import com.google.android.ground.persistence.local.room.entity.LocationOfInterestMutationEntity +import com.google.android.ground.persistence.local.room.entity.MultipleChoiceEntity +import com.google.android.ground.persistence.local.room.entity.OfflineAreaEntity +import com.google.android.ground.persistence.local.room.entity.OptionEntity +import com.google.android.ground.persistence.local.room.entity.SubmissionEntity +import com.google.android.ground.persistence.local.room.entity.SubmissionMutationEntity +import com.google.android.ground.persistence.local.room.entity.SurveyEntity +import com.google.android.ground.persistence.local.room.entity.TaskEntity +import com.google.android.ground.persistence.local.room.entity.TileSourceEntity +import com.google.android.ground.persistence.local.room.entity.UserEntity +import com.google.android.ground.persistence.local.room.fields.EntityState +import com.google.android.ground.persistence.local.room.fields.ExpressionEntityType +import com.google.android.ground.persistence.local.room.fields.MatchEntityType +import com.google.android.ground.persistence.local.room.fields.MultipleChoiceEntityType +import com.google.android.ground.persistence.local.room.fields.MutationEntitySyncStatus +import com.google.android.ground.persistence.local.room.fields.MutationEntityType +import com.google.android.ground.persistence.local.room.fields.OfflineAreaEntityState +import com.google.android.ground.persistence.local.room.fields.TaskEntityType +import com.google.android.ground.persistence.local.room.fields.TileSetEntityState /** * Main entry point to local database API, exposing data access objects (DAOs) for interacting with @@ -49,7 +83,9 @@ import com.google.android.ground.persistence.local.room.fields.* SubmissionEntity::class, SubmissionMutationEntity::class, OfflineAreaEntity::class, - UserEntity::class + UserEntity::class, + ConditionEntity::class, + ExpressionEntity::class, ], version = Config.DB_VERSION, exportSchema = false @@ -57,6 +93,8 @@ import com.google.android.ground.persistence.local.room.fields.* @TypeConverters( TaskEntityType::class, MultipleChoiceEntityType::class, + MatchEntityType::class, + ExpressionEntityType::class, MutationEntityType::class, EntityState::class, GeometryWrapperTypeConverter::class, @@ -92,4 +130,8 @@ abstract class LocalDatabase : RoomDatabase() { abstract fun offlineAreaDao(): OfflineAreaDao abstract fun userDao(): UserDao + + abstract fun conditionDao(): ConditionDao + + abstract fun expressionDao(): ExpressionDao } 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 a4a308d2d0..52a9f8a857 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 @@ -29,12 +29,16 @@ import com.google.android.ground.model.mutation.LocationOfInterestMutation import com.google.android.ground.model.mutation.SubmissionMutation import com.google.android.ground.model.submission.Submission import com.google.android.ground.model.submission.SubmissionData +import com.google.android.ground.model.task.Condition +import com.google.android.ground.model.task.Expression import com.google.android.ground.model.task.MultipleChoice import com.google.android.ground.model.task.Option import com.google.android.ground.model.task.Task +import com.google.android.ground.model.task.TaskId import com.google.android.ground.persistence.local.LocalDataConsistencyException import com.google.android.ground.persistence.local.room.entity.* import com.google.android.ground.persistence.local.room.fields.* +import com.google.android.ground.persistence.local.room.relations.ConditionEntityAndRelations import com.google.android.ground.persistence.local.room.relations.JobEntityAndRelations import com.google.android.ground.persistence.local.room.relations.SurveyEntityAndRelations import com.google.android.ground.persistence.local.room.relations.TaskEntityAndRelations @@ -416,12 +420,21 @@ fun TaskEntityAndRelations.toModelObject(): Task { if (multipleChoiceEntities.isNotEmpty()) { if (multipleChoiceEntities.size > 1) { - Timber.e("More than 1 multiple choice found for task") + Timber.e("More than 1 multiple choice found for task: $taskEntity") } multipleChoice = multipleChoiceEntities[0].toModelObject(optionEntities) } + var condition: Condition? = null + + if (conditionEntityAndRelations.isNotEmpty()) { + if (conditionEntityAndRelations.size > 1) { + Timber.e("More than 1 condition found for task: $taskEntity") + } + condition = conditionEntityAndRelations[0].toModelObject() + } + return Task( taskEntity.id, taskEntity.index, @@ -429,7 +442,8 @@ fun TaskEntityAndRelations.toModelObject(): Task { taskEntity.label!!, taskEntity.isRequired, multipleChoice, - taskEntity.isAddLoiTask + taskEntity.isAddLoiTask, + condition, ) } @@ -438,3 +452,36 @@ fun User.toLocalDataStoreObject() = fun UserEntity.toModelObject() = User(id = id, email = email, displayName = displayName, photoUrl = photoUrl) + +fun Condition.toLocalDataStoreObject(parentTaskId: TaskId) = + ConditionEntity(parentTaskId = parentTaskId, matchType = MatchEntityType.fromMatchType(matchType)) + +fun ConditionEntityAndRelations.toModelObject(): Condition? { + val expressions: List? + + if (expressionEntities.isEmpty()) { + return null + } else { + expressions = expressionEntities.map { it.toModelObject() } + } + + return Condition( + conditionEntity.matchType.toMatchType(), + expressions = expressions, + ) +} + +fun Expression.toLocalDataStoreObject(parentTaskId: TaskId): ExpressionEntity = + ExpressionEntity( + parentTaskId = parentTaskId, + expressionType = ExpressionEntityType.fromExpressionType(expressionType), + taskId = taskId, + optionIds = optionIds.joinToString(",") + ) + +fun ExpressionEntity.toModelObject(): Expression = + Expression( + expressionType = expressionType.toExpressionType(), + taskId = taskId, + optionIds = optionIds?.split(',')?.toSet() ?: setOf(), + ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt new file mode 100644 index 0000000000..579110aeba --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt @@ -0,0 +1,21 @@ +/* + * 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 com.google.android.ground.persistence.local.room.entity.ConditionEntity + +@Dao interface ConditionDao : BaseDao diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt new file mode 100644 index 0000000000..9971178384 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt @@ -0,0 +1,21 @@ +/* + * 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 com.google.android.ground.persistence.local.room.entity.ExpressionEntity + +@Dao interface ExpressionDao : BaseDao diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt new file mode 100644 index 0000000000..13b7cf8836 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt @@ -0,0 +1,41 @@ +/* + * 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.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.android.ground.persistence.local.room.fields.MatchEntityType + +@Entity( + tableName = "condition", + foreignKeys = + [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_task_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("parent_task_id")] +) +data class ConditionEntity( + @ColumnInfo(name = "parent_task_id") @PrimaryKey val parentTaskId: String, + @ColumnInfo(name = "match_type") val matchType: MatchEntityType, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt new file mode 100644 index 0000000000..c5e16c05ff --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt @@ -0,0 +1,44 @@ +/* + * 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.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.android.ground.persistence.local.room.fields.ExpressionEntityType + +@Entity( + tableName = "expression", + foreignKeys = + [ + ForeignKey( + entity = ConditionEntity::class, + parentColumns = ["parent_task_id"], + childColumns = ["parent_task_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("parent_task_id")] +) +data class ExpressionEntity( + @ColumnInfo(name = "parent_task_id") @PrimaryKey val parentTaskId: String, + @ColumnInfo(name = "task_id") val taskId: String, + @ColumnInfo(name = "expression_type") val expressionType: ExpressionEntityType, + // CSV encoded string. + @ColumnInfo(name = "option_ids") val optionIds: String?, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt index f2c61bb848..38a227e630 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt @@ -15,7 +15,11 @@ */ package com.google.android.ground.persistence.local.room.entity -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey import com.google.android.ground.persistence.local.room.fields.TaskEntityType @Entity( @@ -38,5 +42,5 @@ data class TaskEntity( @ColumnInfo(name = "label") val label: String?, @ColumnInfo(name = "is_required") val isRequired: Boolean, @ColumnInfo(name = "job_id") val jobId: String?, - @ColumnInfo(name = "is_add_loi_task") val isAddLoiTask: Boolean + @ColumnInfo(name = "is_add_loi_task") val isAddLoiTask: Boolean, ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt new file mode 100644 index 0000000000..38448ce9d3 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt @@ -0,0 +1,55 @@ +/* + * 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.fields + +import androidx.room.TypeConverter +import com.google.android.ground.model.task.Expression +import com.google.android.ground.persistence.local.room.IntEnum + +enum class ExpressionEntityType(private val intValue: Int) : IntEnum { + UNKNOWN(0), + ANY_OF_SELECTED(1), + ALL_OF_SELECTED(2), + ONE_OF_SELECTED(3); + + override fun intValue(): Int = intValue + + fun toExpressionType(): Expression.ExpressionType = + EXPRESSION_TYPES.getOrDefault(this, Expression.ExpressionType.UNKNOWN) + + companion object { + private val EXPRESSION_TYPES: Map = + mapOf( + Pair(UNKNOWN, Expression.ExpressionType.UNKNOWN), + Pair(ANY_OF_SELECTED, Expression.ExpressionType.ANY_OF_SELECTED), + Pair(ALL_OF_SELECTED, Expression.ExpressionType.ALL_OF_SELECTED), + Pair(ONE_OF_SELECTED, Expression.ExpressionType.ONE_OF_SELECTED), + ) + private val REVERSE_EXPRESSION_TYPES: Map = + EXPRESSION_TYPES.entries.associateBy({ it.value }) { it.key } + + fun fromExpressionType(type: Expression.ExpressionType): ExpressionEntityType = + REVERSE_EXPRESSION_TYPES.getOrDefault(type, UNKNOWN) + + @JvmStatic + @TypeConverter + fun toInt(value: ExpressionEntityType?): Int = IntEnum.toInt(value, UNKNOWN) + + @JvmStatic + @TypeConverter + fun fromInt(intValue: Int): ExpressionEntityType = IntEnum.fromInt(values(), intValue, UNKNOWN) + } +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt new file mode 100644 index 0000000000..69042f4fee --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt @@ -0,0 +1,55 @@ +/* + * 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.fields + +import androidx.room.TypeConverter +import com.google.android.ground.model.task.Condition +import com.google.android.ground.persistence.local.room.IntEnum + +enum class MatchEntityType(private val intValue: Int) : IntEnum { + UNKNOWN(0), + MATCH_ANY(1), + MATCH_ALL(2), + MATCH_ONE(3); + + override fun intValue(): Int = intValue + + fun toMatchType(): Condition.MatchType = + MATCH_TYPES.getOrDefault(this, Condition.MatchType.UNKNOWN) + + companion object { + private val MATCH_TYPES: Map = + mapOf( + Pair(UNKNOWN, Condition.MatchType.UNKNOWN), + Pair(MATCH_ANY, Condition.MatchType.MATCH_ANY), + Pair(MATCH_ALL, Condition.MatchType.MATCH_ALL), + Pair(MATCH_ONE, Condition.MatchType.MATCH_ONE), + ) + private val REVERSE_MATCH_TYPES: Map = + MATCH_TYPES.entries.associateBy({ it.value }) { it.key } + + fun fromMatchType(type: Condition.MatchType): MatchEntityType = + REVERSE_MATCH_TYPES.getOrDefault(type, UNKNOWN) + + @JvmStatic + @TypeConverter + fun toInt(value: MatchEntityType?): Int = IntEnum.toInt(value, UNKNOWN) + + @JvmStatic + @TypeConverter + fun fromInt(intValue: Int): MatchEntityType = IntEnum.fromInt(values(), intValue, UNKNOWN) + } +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt new file mode 100644 index 0000000000..8571b79c8c --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt @@ -0,0 +1,37 @@ +/* + * 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.relations + +import androidx.room.Embedded +import androidx.room.Relation +import com.google.android.ground.persistence.local.room.entity.ConditionEntity +import com.google.android.ground.persistence.local.room.entity.ExpressionEntity + +/** + * Represents relationship between TaskEntity, MultipleChoiceEntity, OptionEntity, and + * ConditionEntity. + * + * Querying any of the below data classes automatically loads the task annotated as @Relation. + */ +data class ConditionEntityAndRelations( + @Embedded val conditionEntity: ConditionEntity, + @Relation( + parentColumn = "parent_task_id", + entityColumn = "parent_task_id", + entity = ExpressionEntity::class + ) + val expressionEntities: List +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt index d671e10c22..c991a02339 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt @@ -17,12 +17,14 @@ package com.google.android.ground.persistence.local.room.relations import androidx.room.Embedded import androidx.room.Relation +import com.google.android.ground.persistence.local.room.entity.ConditionEntity import com.google.android.ground.persistence.local.room.entity.MultipleChoiceEntity import com.google.android.ground.persistence.local.room.entity.OptionEntity import com.google.android.ground.persistence.local.room.entity.TaskEntity /** - * Represents relationship between TaskEntity, MultipleChoiceEntity, and OptionEntity. + * Represents relationship between TaskEntity, MultipleChoiceEntity, OptionEntity, and + * ConditionEntity. * * Querying any of the below data classes automatically loads the task annotated as @Relation. */ @@ -31,5 +33,7 @@ data class TaskEntityAndRelations( @Relation(parentColumn = "id", entityColumn = "task_id") val multipleChoiceEntities: List, @Relation(parentColumn = "id", entityColumn = "task_id", entity = OptionEntity::class) - val optionEntities: List + val optionEntities: List, + @Relation(parentColumn = "id", entityColumn = "parent_task_id", entity = ConditionEntity::class) + val conditionEntityAndRelations: List ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt index e9386d6561..3c3b31fe3e 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt @@ -17,11 +17,14 @@ package com.google.android.ground.persistence.local.room.stores import com.google.android.ground.model.Survey import com.google.android.ground.model.job.Job +import com.google.android.ground.model.task.Condition import com.google.android.ground.model.task.MultipleChoice import com.google.android.ground.model.task.Option import com.google.android.ground.model.task.Task 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.ConditionDao +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.MultipleChoiceDao import com.google.android.ground.persistence.local.room.dao.OptionDao @@ -39,12 +42,21 @@ import kotlinx.coroutines.flow.map @Singleton class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { @Inject lateinit var optionDao: OptionDao + @Inject lateinit var multipleChoiceDao: MultipleChoiceDao + @Inject lateinit var taskDao: TaskDao + @Inject lateinit var jobDao: JobDao + @Inject lateinit var surveyDao: SurveyDao + @Inject lateinit var tileSourceDao: TileSourceDao + @Inject lateinit var conditionDao: ConditionDao + + @Inject lateinit var expressionDao: ExpressionDao + override val surveys: Flow> get() = surveyDao.getAll().map { surveyEntities -> surveyEntities.map { it.toModelObject() } } @@ -80,6 +92,13 @@ class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { options.forEach { insertOrUpdateOption(taskId, it) } } + private suspend fun insertOrUpdateCondition(taskId: String, condition: Condition) { + conditionDao.insertOrUpdate(condition.toLocalDataStoreObject(parentTaskId = taskId)) + condition.expressions.forEach { + expressionDao.insertOrUpdate(it.toLocalDataStoreObject(parentTaskId = taskId)) + } + } + private suspend fun insertOrUpdateMultipleChoice(taskId: String, multipleChoice: MultipleChoice) { multipleChoiceDao.insertOrUpdate(multipleChoice.toLocalDataStoreObject(taskId)) insertOrUpdateOptions(taskId, multipleChoice.options) @@ -90,6 +109,9 @@ class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { if (task.multipleChoice != null) { insertOrUpdateMultipleChoice(task.id, task.multipleChoice) } + if (task.condition != null) { + insertOrUpdateCondition(task.id, task.condition) + } } private suspend fun insertOrUpdateTasks(jobId: String, tasks: Collection) = diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt new file mode 100644 index 0000000000..ff62401373 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt @@ -0,0 +1,94 @@ +/* + * 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.remote.firebase.schema + +import com.google.android.ground.model.task.Condition +import com.google.android.ground.model.task.Condition.MatchType +import com.google.android.ground.model.task.Expression +import com.google.android.ground.model.task.Expression.ExpressionType +import timber.log.Timber + +/** Converts between Firestore nested objects and [Condition] instances. */ +internal object ConditionConverter { + fun ConditionNestedObject.toCondition(): Condition? { + val matchType = matchType.toMatchType() + if (matchType == MatchType.UNKNOWN) { + Timber.e("Unsupported matchType: $matchType") + return null + } + return Condition( + matchType = matchType, + expressions = (expressions ?: listOf()).toExpressions(), + ) + } + + // Note: Key value must be in sync with web app. + private fun String?.toMatchType(): MatchType = + when (this) { + "MATCH_ANY" -> { + MatchType.MATCH_ANY + } + "MATCH_ALL" -> { + MatchType.MATCH_ALL + } + "MATCH_ONE" -> { + MatchType.MATCH_ONE + } + else -> { + Timber.v("Unknown MatchType received: $this") + MatchType.UNKNOWN + } + } + + private fun List.toExpressions(): List = + this.mapNotNull { + val expressionType = it.expressionType.toExpressionType() + if (expressionType == ExpressionType.UNKNOWN) { + Timber.e("Unsupported expressionType: ${it.expressionType}, skipping expression.") + null + } else if (it.taskId == null) { + Timber.e("Empty task ID encountered, skipping expression.") + null + } else if (it.optionIds == null) { + Timber.e("Empty option IDs encountered, skipping expression.") + null + } else { + Expression( + expressionType = expressionType, + taskId = it.taskId, + optionIds = it.optionIds.toSet(), + ) + } + } + + private fun String?.toExpressionType(): ExpressionType = + when (this) { + "ANY_OF_SELECTED" -> { + ExpressionType.ANY_OF_SELECTED + } + "ALL_OF_SELECTED" -> { + ExpressionType.ALL_OF_SELECTED + } + "ONE_OF_SELECTED" -> { + ExpressionType.ONE_OF_SELECTED + } + else -> { + Timber.v("Unknown ExpressionType received: $this") + ExpressionType.UNKNOWN + } + } +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionNestedObject.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionNestedObject.kt new file mode 100644 index 0000000000..979ceaebc5 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionNestedObject.kt @@ -0,0 +1,34 @@ +/* + * 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.remote.firebase.schema + +import com.google.firebase.firestore.IgnoreExtraProperties + +/** Firestore representation of a task condition. */ +@IgnoreExtraProperties +data class ConditionNestedObject( + val matchType: String? = null, + val expressions: List? = null, +) + +/** Firestore representation of a task condition expression. */ +@IgnoreExtraProperties +data class ExpressionNestedObject( + val expressionType: String? = null, + val taskId: String? = null, + val optionIds: List? = null +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskConverter.kt index 691eee4755..46db23cc9d 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskConverter.kt @@ -19,6 +19,7 @@ package com.google.android.ground.persistence.remote.firebase.schema import com.google.android.ground.model.job.Job import com.google.android.ground.model.task.MultipleChoice import com.google.android.ground.model.task.Task +import com.google.android.ground.persistence.remote.firebase.schema.ConditionConverter.toCondition import com.google.android.ground.persistence.remote.firebase.schema.MultipleChoiceConverter.toMultipleChoice import timber.log.Timber @@ -41,7 +42,8 @@ internal object TaskConverter { em.label!!, em.required != null && em.required, multipleChoice, - em.addLoiTask ?: false + em.addLoiTask ?: false, + em.condition?.toCondition() ) } diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskNestedObject.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskNestedObject.kt index b6ce275c25..b4188409f6 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskNestedObject.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/TaskNestedObject.kt @@ -27,5 +27,6 @@ data class TaskNestedObject( val label: String? = null, val options: Map? = null, val required: Boolean? = null, - val addLoiTask: Boolean? = false + val addLoiTask: Boolean? = false, + val condition: ConditionNestedObject? = null, ) 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 1ae570df6b..e3a54224a7 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 @@ -76,7 +76,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { viewPager.offscreenPageLimit = 1 loadTasks(viewModel.tasks) - lifecycleScope.launch { viewModel.currentPosition.collect { onTaskChanged(it) } } + lifecycleScope.launch { viewModel.currentTaskId.collect { onTaskChanged() } } viewPager.registerOnPageChangeCallback( object : ViewPager2.OnPageChangeCallback() { @@ -115,16 +115,20 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { } // Reset progress bar - progressBar.progress = 0 - progressBar.max = (tasks.size - 1) * PROGRESS_SCALE + val (start, taskSize) = viewModel.getPositionInTaskSequence() + progressBar.progress = start + progressBar.max = (taskSize - 1) * PROGRESS_SCALE } - private fun onTaskChanged(index: Int) { - viewPager.currentItem = index + private fun onTaskChanged() { + viewPager.currentItem = viewModel.getAbsolutePosition() + // Reset progress bar + val (currIndex, taskSize) = viewModel.getPositionInTaskSequence() + progressBar.max = (taskSize - 1) * PROGRESS_SCALE progressBar.clearAnimation() - val progressAnimator = ValueAnimator.ofInt(progressBar.progress, index * PROGRESS_SCALE) + val progressAnimator = ValueAnimator.ofInt(progressBar.progress, currIndex * PROGRESS_SCALE) progressAnimator.duration = 400L progressAnimator.interpolator = FastOutSlowInInterpolator() @@ -140,7 +144,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { false } else { // Otherwise, select the previous step. - viewModel.updateCurrentPosition(viewModel.getVisibleTaskPosition() - 1) + viewModel.step(-1) true } 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 cdefe5e5c8..6bec14ebef 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 @@ -24,6 +24,7 @@ import com.google.android.ground.model.Survey import com.google.android.ground.model.job.Job 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.repository.LocationOfInterestRepository import com.google.android.ground.repository.SurveyRepository @@ -80,12 +81,14 @@ internal constructor( private val jobId: String? = 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") + // 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 } @@ -109,8 +112,9 @@ internal constructor( private val data: MutableMap = LinkedHashMap() - // Tracks the task's current position in the list of tasks for the current job - var currentPosition: StateFlow = savedStateHandle.getStateFlow(TASK_POSITION_KEY, 0) + // Tracks the current task ID to compute the position in the list of tasks for the current job. + var currentTaskId: StateFlow = + savedStateHandle.getStateFlow(TASK_POSITION_ID, tasks.first().id) lateinit var submissionId: String @@ -142,23 +146,22 @@ 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(position: Int, taskViewModel: AbstractTaskViewModel) { - check(position != 0) + fun onPreviousClicked(taskViewModel: AbstractTaskViewModel) { + check(getPositionInTaskSequence().first != 0) val validationError = taskViewModel.validate() if (validationError != null) { popups.get().ErrorPopup().show(validationError) return } - - updateCurrentPosition(position - 1) + step(-1) } /** * Validates the user's input and displays an error if the user input was invalid. Progresses to * the next Data Collection screen if the user input was valid. */ - suspend fun onNextClicked(position: Int, taskViewModel: AbstractTaskViewModel) { + suspend fun onNextClicked(taskViewModel: AbstractTaskViewModel) { val validationError = taskViewModel.validate() if (validationError != null) { popups.get().ErrorPopup().show(validationError) @@ -167,8 +170,8 @@ internal constructor( data[taskViewModel.task] = taskViewModel.taskValue.firstOrNull() - if (!isLastPosition(position)) { - updateCurrentPosition(position + 1) + if (!isLastPosition()) { + step(1) } else { val deltas = data.map { (task, value) -> ValueDelta(task.id, task.type, value) } saveChanges(deltas) @@ -187,28 +190,74 @@ internal constructor( externalScope.launch(ioDispatcher) { submitDataUseCase.invoke(loiId, job, surveyId, deltas) } } - /** Returns the position of the task fragment visible to the user. */ - fun getVisibleTaskPosition() = currentPosition.value - - /** Displays the task at the given position to the user. */ - fun updateCurrentPosition(position: Int) { - savedStateHandle[TASK_POSITION_KEY] = position + fun getAbsolutePosition(): Int { + if (currentTaskId.value == "") { + return 0 + } + return tasks.indexOf(tasks.first { it.id == currentTaskId.value }) } - /** Returns true if the given task position is last. */ - fun isLastPosition(taskPosition: Int): Boolean { - val finalTaskPosition = tasks.size - 1 + /** + * Get the current index within the computed task sequence, and the number of tasks in the + * sequence, e.g (0, 2) means the first task of 2. + */ + fun getPositionInTaskSequence(): Pair { + var currentIndex = 0 + var size = 0 + getTaskSequence().forEachIndexed { index, task -> + if (task.id == currentTaskId.value) { + currentIndex = index + } + size++ + } + return currentIndex to size + } - assert(finalTaskPosition >= 0) - assert(taskPosition in 0..finalTaskPosition) + /** + * Retrieves the current task sequence given the inputs and conditions set on the tasks. Setting a + * start ID will always generate a sequence with the start ID as the first element, and if + * reversed is set, will generate the previous tasks from there. + */ + private fun getTaskSequence(startId: String? = null, reversed: Boolean = false): Sequence { + val startIndex = tasks.indexOf(tasks.first { it.id == (startId ?: tasks[0].id) }) + return if (reversed) { + tasks.subList(0, startIndex + 1).reversed() + } else { + tasks.subList(startIndex, tasks.size) + } + .let { tasks -> + tasks.asSequence().filter { it.condition == null || evaluateCondition(it.condition) } + } + } - return taskPosition == finalTaskPosition + /** Displays the task at the relative position to the current one. Supports negative steps. */ + fun step(stepCount: Int) { + val reverse = stepCount < 0 + val task = + getTaskSequence(startId = currentTaskId.value, reversed = reverse) + .take(Math.abs(stepCount) + 1) + .last() + savedStateHandle[TASK_POSITION_ID] = task.id } + /** Returns true if the given task index is last if set, or the current active task. */ + fun isLastPosition(taskIndex: Int? = null): Boolean = + if (taskIndex == null) { + currentTaskId.value + } else { + tasks[taskIndex].id + } == getTaskSequence().last().id + + /** Evaluates the task condition against the current inputs. */ + private fun evaluateCondition(condition: Condition): Boolean = + condition.fulfilledBy( + data.mapNotNull { (task, value) -> value?.let { task.id to value } }.toMap() + ) + companion object { private const val TASK_JOB_ID_KEY = "jobId" private const val TASK_LOI_ID_KEY = "locationOfInterestId" - private const val TASK_POSITION_KEY = "currentPosition" + private const val TASK_POSITION_ID = "currentTaskId" 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 3cde535964..bdfa41b2fe 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 @@ -154,11 +154,11 @@ abstract class AbstractTaskFragment : AbstractFragmen } private fun moveToPrevious() { - dataCollectionViewModel.onPreviousClicked(position, viewModel) + dataCollectionViewModel.onPreviousClicked(viewModel) } fun moveToNext() { - lifecycleScope.launch { dataCollectionViewModel.onNextClicked(position, viewModel) } + lifecycleScope.launch { dataCollectionViewModel.onNextClicked(viewModel) } } fun addUndoButton() = diff --git a/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt b/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt new file mode 100644 index 0000000000..a387a1fcd7 --- /dev/null +++ b/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt @@ -0,0 +1,229 @@ +/* + * 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.task + +import com.google.android.ground.model.submission.MultipleChoiceResponse +import com.google.android.ground.model.submission.Value +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +typealias ExpressionTestCase = List>> + +typealias ConditionTestCase = List> + +const val TASK_A_ID = "task-A-id-123" +const val TASK_A_OPTION_X = "task-A-option-id-x" +const val TASK_A_OPTION_Y = "task-A-option-id-y" +const val TASK_A_OPTION_Z = "task-A-option-id-z" + +const val TASK_B_ID = "task-B-id-123" +const val TASK_B_OPTION_X = "task-B-option-id-x" +const val TASK_B_OPTION_Y = "task-B-option-id-y" + +val TASK_A_EXPRESSION = + Expression( + expressionType = Expression.ExpressionType.ANY_OF_SELECTED, + taskId = TASK_A_ID, + optionIds = setOf(TASK_A_OPTION_X) + ) +val Task_B_EXPRESSION = + Expression( + expressionType = Expression.ExpressionType.ANY_OF_SELECTED, + taskId = TASK_B_ID, + optionIds = setOf(TASK_B_OPTION_X) + ) + +fun makeValue(vararg selectedOptions: String) = + MultipleChoiceResponse(multipleChoice = null, selectedOptionIds = selectedOptions.toList()) + +class ConditionTest { + @Test + fun `Condition of type MATCH_ANY works`() { + val condition = + Condition( + matchType = Condition.MatchType.MATCH_ALL, + listOf(TASK_A_EXPRESSION, Task_B_EXPRESSION) + ) + listOf( + // Expressions evaluate to [true, true]. + true to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [true, false]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + // Expressions evaluate to [false, true]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [false, false]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + ) + .test(condition) + } + + @Test + fun `Condition of type MATCH_ALL works`() { + val condition = + Condition( + matchType = Condition.MatchType.MATCH_ALL, + listOf(TASK_A_EXPRESSION, Task_B_EXPRESSION) + ) + listOf( + // Expressions evaluate to [true, true]. + true to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [true, false]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + // Expressions evaluate to [false, true]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [false, false]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + ) + .test(condition) + } + + @Test + fun `Condition of type MATCH_ONE works`() { + val condition = + Condition( + matchType = Condition.MatchType.MATCH_ONE, + listOf(TASK_A_EXPRESSION, Task_B_EXPRESSION) + ) + listOf( + // Expressions evaluate to [true, true]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [true, false]. + true to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + // Expressions evaluate to [false, true]. + true to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_X)), + // Expressions evaluate to [false, false]. + false to + mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_Y), TASK_B_ID to makeValue(TASK_B_OPTION_Y)), + ) + .test(condition) + } + + @Test + fun `Condition of type UNKNOWN throws an error`() { + val condition = + Condition( + matchType = Condition.MatchType.UNKNOWN, + listOf(TASK_A_EXPRESSION, Task_B_EXPRESSION) + ) + assertFailsWith { + condition.fulfilledBy(mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X))) + } + } + + @Test + fun `Expression of type ANY_OF_SELECTED works`() { + val expression = + Expression( + expressionType = Expression.ExpressionType.ANY_OF_SELECTED, + taskId = TASK_A_ID, + optionIds = setOf(TASK_A_OPTION_X, TASK_A_OPTION_Y) + ) + listOf( + true to (TASK_A_ID to makeValue(TASK_A_OPTION_X)), + true to (TASK_A_ID to makeValue(TASK_A_OPTION_Y)), + true to (TASK_A_ID to makeValue(TASK_A_OPTION_X, TASK_A_OPTION_Y)), + false to (TASK_A_ID to makeValue(TASK_A_OPTION_Z)), + false to (TASK_B_ID to makeValue(TASK_A_OPTION_X)), + ) + .test(expression) + } + + @Test + fun `Expression of type ALL_OF_SELECTED works`() { + val expression = + Expression( + expressionType = Expression.ExpressionType.ALL_OF_SELECTED, + taskId = TASK_A_ID, + optionIds = setOf(TASK_A_OPTION_X, TASK_A_OPTION_Y) + ) + listOf( + false to (TASK_A_ID to makeValue(TASK_A_OPTION_X)), + false to (TASK_A_ID to makeValue(TASK_A_OPTION_Y)), + true to (TASK_A_ID to makeValue(TASK_A_OPTION_X, TASK_A_OPTION_Y)), + false to (TASK_A_ID to makeValue(TASK_A_OPTION_Z)), + false to (TASK_B_ID to makeValue(TASK_A_OPTION_X, TASK_A_OPTION_Y)), + ) + .test(expression) + } + + @Test + fun `Expression of type ONE_OF_SELECTED works`() { + val expression = + Expression( + expressionType = Expression.ExpressionType.ONE_OF_SELECTED, + taskId = TASK_A_ID, + optionIds = setOf(TASK_A_OPTION_X, TASK_A_OPTION_Y) + ) + listOf( + true to (TASK_A_ID to makeValue(TASK_A_OPTION_X)), + true to (TASK_A_ID to makeValue(TASK_A_OPTION_Y)), + false to (TASK_A_ID to makeValue(TASK_A_OPTION_X, TASK_A_OPTION_Y)), + false to (TASK_A_ID to makeValue(TASK_A_OPTION_Z)), + false to (TASK_B_ID to makeValue(TASK_A_OPTION_X)), + ) + .test(expression) + } + + @Test + fun `Expression of type UNKNOWN throws an error`() { + val expression = + Expression( + expressionType = Expression.ExpressionType.UNKNOWN, + taskId = TASK_A_ID, + optionIds = setOf(TASK_A_OPTION_X, TASK_A_OPTION_Y) + ) + assertFailsWith { + expression.fulfilledBy(mapOf(TASK_A_ID to makeValue(TASK_A_OPTION_X))) + } + } + + private fun ConditionTestCase.test(condition: Condition) { + this.forEachIndexed { index, testCase -> + val message = + "Type ${condition.matchType}, case $index: Fulfilled should be ${testCase.first} with ${testCase.second}" + if (testCase.first) { + assertTrue(condition.fulfilledBy(testCase.second), message) + } else { + assertFalse(condition.fulfilledBy(testCase.second), message) + } + } + } + + private fun ExpressionTestCase.test(expression: Expression) { + this.forEachIndexed { index, testCase -> + val message = + "Type ${expression.expressionType}, case $index: Fulfilled should be ${testCase.first} with ${testCase.second}" + if (testCase.first) { + assertTrue(expression.fulfilledBy(mapOf(testCase.second)), message) + } else { + assertFalse(expression.fulfilledBy(mapOf(testCase.second)), message) + } + } + } +} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverterTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverterTest.kt new file mode 100644 index 0000000000..a06c0d15eb --- /dev/null +++ b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverterTest.kt @@ -0,0 +1,178 @@ +/* + * 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.remote.firebase.schema + +import com.google.android.ground.model.task.Condition +import com.google.android.ground.model.task.Expression +import com.google.android.ground.persistence.remote.firebase.schema.ConditionConverter.toCondition +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +const val TASK_ID = "task-id-123" +const val TEST_MATCH_TYPE = "MATCH_ANY" +const val TEST_OPTION_ID_A = "option-id-a-123" +const val TEST_OPTION_ID_B = "option-id-b-123" +const val TEST_OPTION_ID_C = "option-id-c-123" +val TEST_OPTION_IDS = listOf(TEST_OPTION_ID_A, TEST_OPTION_ID_B, TEST_OPTION_ID_C) + +class ConditionConverterTest { + @Test + fun `toCondition() converts match types`() { + with(ConditionConverter) { + listOf( + "MATCH_ANY" to Condition.MatchType.MATCH_ANY, + "MATCH_ALL" to Condition.MatchType.MATCH_ALL, + "MATCH_ONE" to Condition.MatchType.MATCH_ONE + ) + .forEach { + assertThat(ConditionNestedObject(matchType = it.first).toCondition()?.matchType) + .isEqualTo(it.second) + } + } + } + + @Test + fun `toCondition() returns null for invalid match types`() { + assertThat(ConditionNestedObject().toCondition()).isNull() + assertThat(ConditionNestedObject(matchType = "MATCH_TWO").toCondition()).isNull() + // Case sensitive. + assertThat(ConditionNestedObject(matchType = "match_any").toCondition()).isNull() + } + + @Test + fun `toCondition() converts expressions`() { + val expressions = + ConditionNestedObject( + matchType = TEST_MATCH_TYPE, + expressions = + listOf( + ExpressionNestedObject( + expressionType = "ANY_OF_SELECTED", + taskId = TASK_ID, + optionIds = TEST_OPTION_IDS + ) + ) + ) + .toCondition() + ?.expressions + assertThat(expressions?.size).isEqualTo(1) + assertThat(expressions?.get(0)).isNotNull() + with(expressions?.get(0)!!) { + assertThat(expressionType).isEqualTo(Expression.ExpressionType.ANY_OF_SELECTED) + assertThat(taskId).isEqualTo(TASK_ID) + assertThat(optionIds).isEqualTo(setOf(TEST_OPTION_ID_A, TEST_OPTION_ID_B, TEST_OPTION_ID_C)) + } + } + + @Test + fun `toCondition() converts expression types`() { + val conditionObjectWithExpressionType = { expressionType: String -> + ConditionNestedObject( + matchType = TEST_MATCH_TYPE, + expressions = + listOf( + ExpressionNestedObject( + expressionType = expressionType, + taskId = TASK_ID, + optionIds = TEST_OPTION_IDS, + ) + ) + ) + } + listOf( + "ANY_OF_SELECTED" to Expression.ExpressionType.ANY_OF_SELECTED, + "ALL_OF_SELECTED" to Expression.ExpressionType.ALL_OF_SELECTED, + "ONE_OF_SELECTED" to Expression.ExpressionType.ONE_OF_SELECTED, + ) + .forEach { + val condition = conditionObjectWithExpressionType(it.first).toCondition() + val expressionType = condition?.expressions?.get(0)?.expressionType + assertThat(expressionType).isEqualTo(it.second) + } + } + + @Test + fun `toCondition() filters out invalid expression types`() { + assertThat( + ConditionNestedObject( + matchType = TEST_MATCH_TYPE, + expressions = + listOf( + ExpressionNestedObject(expressionType = null, taskId = TASK_ID), + ExpressionNestedObject(expressionType = "TWO_OF_SELECTED", taskId = TASK_ID), + // Case sensitive. + ExpressionNestedObject(expressionType = "any_of_selected", taskId = TASK_ID), + ) + ) + .toCondition() + ?.expressions + ) + .isEqualTo(listOf()) + } + + @Test + fun `toCondition() filters out expressions with no task ID`() { + assertThat( + ConditionNestedObject( + matchType = TEST_MATCH_TYPE, + expressions = + listOf( + ExpressionNestedObject( + expressionType = "ANY_OF_SELECTED", + taskId = TASK_ID, + optionIds = TEST_OPTION_IDS, + ), + // Missing task ID. + ExpressionNestedObject( + expressionType = "ANY_OF_SELECTED", + optionIds = TEST_OPTION_IDS, + ), + ) + ) + .toCondition() + ?.expressions + ?.size + ) + .isEqualTo(1) + } + + @Test + fun `toCondition() filters out expressions with no option IDs`() { + assertThat( + ConditionNestedObject( + matchType = TEST_MATCH_TYPE, + expressions = + listOf( + ExpressionNestedObject( + expressionType = "ANY_OF_SELECTED", + taskId = TASK_ID, + optionIds = TEST_OPTION_IDS, + ), + // Missing option IDs. + ExpressionNestedObject( + expressionType = "ANY_OF_SELECTED", + taskId = TASK_ID, + ), + ) + ) + .toCondition() + ?.expressions + ?.size + ) + .isEqualTo(1) + } +}