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 6c049c4a23..b5ac94362b 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 @@ -205,7 +205,13 @@ internal constructor( viewModel?.let { created -> val taskData = if (shouldLoadFromDraft) getValueFromDraft(state.job, task) else null - created.initialize(state.job, task, taskData) + created.initialize( + job = state.job, + task = task, + taskData = taskData, + isFirstPosition = { isFirstPosition(task.id) }, + isLastPosition = { isLastPositionWithValue(task, it) }, + ) updateDataAndInvalidateTasks(task, taskData) taskViewModels.value[task.id] = created } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/ButtonActionState.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/ButtonActionState.kt new file mode 100644 index 0000000000..73dc864a85 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/ButtonActionState.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 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.ui.datacollection.components.refactor + +import org.groundplatform.android.ui.datacollection.components.ButtonAction + +data class ButtonActionState( + val action: ButtonAction, + val isEnabled: Boolean = true, + val isVisible: Boolean = true, +) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/TaskButton.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/TaskButton.kt new file mode 100644 index 0000000000..0e59dafcb4 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/components/refactor/TaskButton.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 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.ui.datacollection.components.refactor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.theme.AppTheme + +@Composable +fun TaskButton( + modifier: Modifier = Modifier, + state: ButtonActionState, + onClick: (ButtonAction) -> Unit, +) { + when (state.action.theme) { + ButtonAction.Theme.DARK_GREEN -> + Button(modifier = modifier, onClick = { onClick(state.action) }, enabled = state.isEnabled) { + Content(action = state.action) + } + ButtonAction.Theme.LIGHT_GREEN -> + FilledTonalButton( + modifier = modifier, + onClick = { onClick(state.action) }, + enabled = state.isEnabled, + ) { + Content(action = state.action) + } + ButtonAction.Theme.OUTLINED -> + OutlinedButton( + modifier = modifier, + onClick = { onClick(state.action) }, + enabled = state.isEnabled, + ) { + Content(action = state.action) + } + ButtonAction.Theme.TRANSPARENT -> + OutlinedButton( + modifier = modifier, + border = null, + onClick = { onClick(state.action) }, + enabled = state.isEnabled, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Content(action = state.action) + } + } +} + +@Composable +private fun Content(modifier: Modifier = Modifier, action: ButtonAction) { + when { + action.drawableId != null -> { + Icon( + modifier = modifier, + imageVector = ImageVector.vectorResource(id = action.drawableId), + contentDescription = action.contentDescription?.let { resId -> stringResource(resId) }, + ) + } + action.textId != null -> { + Text(modifier = modifier, text = stringResource(id = action.textId)) + } + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun TaskButtonAllPreview() { + AppTheme { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ButtonAction.entries.forEach { action -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TaskButton(state = ButtonActionState(action), onClick = {}) + TaskButton(state = ButtonActionState(action), onClick = {}) + } + } + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractMapTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractMapTaskViewModel.kt index bdfeaa1fc6..70beb91677 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractMapTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractMapTaskViewModel.kt @@ -22,7 +22,7 @@ import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.ui.common.BaseMapViewModel /** Defines the state of an inflated Map [Task] and controls its UI. */ -open class AbstractMapTaskViewModel internal constructor() : AbstractTaskViewModel() { +abstract class AbstractMapTaskViewModel internal constructor() : AbstractTaskViewModel() { /** Allows control for triggering the location lock programmatically. */ private val _enableLocationLockFlow = MutableStateFlow(LocationLockEnabledState.UNKNOWN) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt index 88b76ee99b..893641c7a2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -240,14 +240,15 @@ abstract class AbstractTaskFragment : AbstractFragmen Column(modifier = Modifier.fillMaxWidth()) { HeaderCard() Spacer(Modifier.height(12.dp)) - ActionButtonsRow() + Footer() } } else { - ActionButtonsRow() + Footer() } } } + @Suppress("UnusedPrivateMember") // andreia: revert this later @Composable private fun ActionButtonsRow() { Row( @@ -258,6 +259,33 @@ abstract class AbstractTaskFragment : AbstractFragmen } } + @Composable + private fun Footer() { + val taskActionButtonsStates by viewModel.taskActionButtonStates.collectAsStateWithLifecycle() + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + taskActionButtonsStates.forEach { state -> + if (state.isVisible) { // andreia:review + org.groundplatform.android.ui.datacollection.components.refactor.TaskButton( + state = state, + onClick = { handleButtonClick(state.action) }, + ) + } + } + } + } + + private fun handleButtonClick(action: ButtonAction) { + when (action) { + // Navigation actions + ButtonAction.PREVIOUS -> moveToPrevious() + ButtonAction.NEXT, + ButtonAction.DONE -> handleNext() + ButtonAction.SKIP -> onSkip() + // Task-specific actions - delegate to ViewModel + else -> viewModel.onButtonClick(action) + } + } + // This function can allow any task to show a Header card on top of the Button row. open fun shouldShowHeader() = false diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt index 3fb9f2164c..2ef04d0963 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt @@ -23,21 +23,36 @@ import org.groundplatform.android.R import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.submission.SkippedTaskData import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.model.submission.isNotNullOrEmpty import org.groundplatform.android.model.submission.isNullOrEmpty import org.groundplatform.android.model.task.Task import org.groundplatform.android.ui.common.AbstractViewModel +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState /** Defines the state of an inflated [Task] and controls its UI. */ -open class AbstractTaskViewModel internal constructor() : AbstractViewModel() { +abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel() { /** Current value. */ private val _taskDataFlow: MutableStateFlow = MutableStateFlow(null) val taskTaskData: StateFlow = _taskDataFlow.asStateFlow() + abstract val taskActionButtonStates: StateFlow> + lateinit var task: Task + private lateinit var isFirstPosition: () -> Boolean + private lateinit var isLastPositionWithValue: (TaskData?) -> Boolean - open fun initialize(job: Job, task: Task, taskData: TaskData?) { + open fun initialize( + job: Job, + task: Task, + taskData: TaskData?, + isFirstPosition: () -> Boolean, + isLastPosition: (TaskData?) -> Boolean, + ) { this.task = task + this.isFirstPosition = isFirstPosition + this.isLastPositionWithValue = isLastPosition setValue(taskData) } @@ -74,4 +89,50 @@ open class AbstractTaskViewModel internal constructor() : AbstractViewModel() { fun isTaskOptional(): Boolean = !task.isRequired fun hasNoData(): Boolean = taskTaskData.value.isNullOrEmpty() + + fun getPreviousButtonState(): ButtonActionState = + ButtonActionState( + action = ButtonAction.PREVIOUS, + isEnabled = !isFirstPosition(), + isVisible = true, + ) + + fun getNextButtonState(taskData: TaskData?, hideIfEmpty: Boolean = false): ButtonActionState { + val isVisible = if (hideIfEmpty) taskData.isNotNullOrEmpty() else true + return if (isLastPositionWithValue(taskData)) { + ButtonActionState( + action = ButtonAction.DONE, + isEnabled = taskData.isNotNullOrEmpty(), + isVisible = isVisible, + ) + } else { + ButtonActionState( + action = ButtonAction.NEXT, + isEnabled = taskData.isNotNullOrEmpty(), + isVisible = isVisible, + ) + } + } + + fun getSkipButtonState(taskData: TaskData?): ButtonActionState = + ButtonActionState( + action = ButtonAction.SKIP, + isEnabled = isTaskOptional(), + isVisible = isTaskOptional() && taskData.isNullOrEmpty(), + ) + + fun getUndoButtonState(taskData: TaskData?): ButtonActionState = + ButtonActionState( + action = ButtonAction.UNDO, + isEnabled = taskData.isNotNullOrEmpty(), + isVisible = taskData.isNotNullOrEmpty(), + ) + + open fun onButtonClick(action: ButtonAction) { + if (action == ButtonAction.UNDO) { + clearResponse() + } else { + // Subclasses handle other actions + } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskViewModel.kt index b22b017f20..461da9d70d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskViewModel.kt @@ -15,12 +15,23 @@ */ package org.groundplatform.android.ui.datacollection.tasks.date +import androidx.lifecycle.viewModelScope import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.groundplatform.android.model.submission.DateTimeTaskData.Companion.fromMillis +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel class DateTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } fun updateResponse(date: Date) { setValue(fromMillis(date.time)) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskViewModel.kt index 3629e7bc41..8c0a0b4236 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskViewModel.kt @@ -15,8 +15,20 @@ */ package org.groundplatform.android.ui.datacollection.tasks.instruction +import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel @Suppress("EmptyClassBlock") -class InstructionTaskViewModel @Inject constructor() : AbstractTaskViewModel() {} +class InstructionTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModel.kt index b6265c5d42..5919e66302 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModel.kt @@ -16,15 +16,26 @@ package org.groundplatform.android.ui.datacollection.tasks.location import android.location.Location +import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlin.lazy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.groundplatform.android.common.Constants.ACCURACY_THRESHOLD_IN_M import org.groundplatform.android.model.geometry.Point import org.groundplatform.android.model.submission.CaptureLocationTaskData +import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.model.submission.isNullOrEmpty +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractMapTaskViewModel import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState import org.groundplatform.android.ui.map.gms.getAccuracyOrNull @@ -42,6 +53,20 @@ class CaptureLocationTaskViewModel @Inject constructor() : AbstractMapTaskViewMo location != null && accuracy <= ACCURACY_THRESHOLD_IN_M } + override val taskActionButtonStates: StateFlow> by lazy { + combine(isCaptureEnabled, taskTaskData) { captureEnabled, taskData -> + listOf( + getPreviousButtonState(), + getSkipButtonState(taskData), + getUndoButtonState(taskData), + getCaptureLocationButtonState(captureEnabled, taskData), + getNextButtonState(taskData, hideIfEmpty = true), + ) + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + fun updateLocation(location: Location) { _lastLocation.update { location } } @@ -64,4 +89,22 @@ class CaptureLocationTaskViewModel @Inject constructor() : AbstractMapTaskViewMo ) } } + + private fun getCaptureLocationButtonState( + captureEnabled: Boolean, + taskData: TaskData?, + ): ButtonActionState = + ButtonActionState( + action = ButtonAction.CAPTURE_LOCATION, + isEnabled = captureEnabled, + isVisible = taskData.isNullOrEmpty(), + ) + + override fun onButtonClick(action: ButtonAction) { + if (action == ButtonAction.CAPTURE_LOCATION) { + updateResponse() + } else { + super.onButtonClick(action) + } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt index 90fed19036..7c2d4f64de 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt @@ -15,9 +15,13 @@ */ package org.groundplatform.android.ui.datacollection.tasks.multiplechoice +import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.groundplatform.android.R import org.groundplatform.android.common.Constants @@ -28,18 +32,31 @@ import org.groundplatform.android.model.submission.TaskData import org.groundplatform.android.model.task.MultipleChoice.Cardinality.SELECT_MULTIPLE import org.groundplatform.android.model.task.Option import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel class MultipleChoiceTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + private val _items: MutableStateFlow> = MutableStateFlow(emptyList()) val items: StateFlow> = _items private val selectedIds: MutableSet = mutableSetOf() private var otherText: String = "" - override fun initialize(job: Job, task: Task, taskData: TaskData?) { - super.initialize(job, task, taskData) + override fun initialize( + job: Job, + task: Task, + taskData: TaskData?, + isFirstPosition: () -> Boolean, + isLastPosition: (TaskData?) -> Boolean, + ) { + super.initialize(job, task, taskData, isFirstPosition, isLastPosition) loadPendingSelections() updateMultipleChoiceItems() } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskViewModel.kt index 15c2a4e92b..468af8fbdb 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskViewModel.kt @@ -17,15 +17,26 @@ package org.groundplatform.android.ui.datacollection.tasks.number import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.groundplatform.android.model.submission.NumberTaskData import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel class NumberTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + /** Transcoded text to be displayed for the current [TaskData]. */ val responseText: LiveData = taskTaskData.filterIsInstance().map { it?.number ?: "" }.asLiveData() diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt index e6a8968950..6af03453a7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt @@ -22,20 +22,37 @@ import androidx.lifecycle.viewModelScope import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.groundplatform.android.data.remote.firebase.FirebaseStorageManager import org.groundplatform.android.model.submission.PhotoTaskData import org.groundplatform.android.model.submission.isNotNullOrEmpty import org.groundplatform.android.repository.UserMediaRepository +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel import timber.log.Timber class PhotoTaskViewModel @Inject constructor(private val userMediaRepository: UserMediaRepository) : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { + listOf( + getPreviousButtonState(), + getUndoButtonState(it), + getSkipButtonState(it), + getNextButtonState(it), + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + /** * Task id waiting for a photo result. As only one photo result is returned at a time, we can * directly map it 1:1 with the task waiting for a photo result. diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt index 32e4a4360d..a1dff99469 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt @@ -18,6 +18,10 @@ package org.groundplatform.android.ui.datacollection.tasks.point import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.uuid.OfflineUuidGenerator @@ -26,7 +30,10 @@ import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.getDefaultColor import org.groundplatform.android.model.submission.DropPinTaskData import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.model.submission.isNullOrEmpty import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractMapTaskViewModel import org.groundplatform.android.ui.map.Feature @@ -43,8 +50,28 @@ constructor( var instructionsDialogShown: Boolean by localValueStore::dropPinInstructionsShown var captureLocation: Boolean = false - override fun initialize(job: Job, task: Task, taskData: TaskData?) { - super.initialize(job, task, taskData) + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { + listOf( + getPreviousButtonState(), + getSkipButtonState(it), + getUndoButtonState(it), + getDropPinButtonState(it), + getNextButtonState(it, hideIfEmpty = true), + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + + override fun initialize( + job: Job, + task: Task, + taskData: TaskData?, + isFirstPosition: () -> Boolean, + isLastPosition: (TaskData?) -> Boolean, + ) { + super.initialize(job, task, taskData, isFirstPosition, isLastPosition) pinColor = job.getDefaultColor() // Drop a marker for current value @@ -83,4 +110,19 @@ constructor( } fun shouldShowInstructionsDialog() = !instructionsDialogShown && !captureLocation + + private fun getDropPinButtonState(taskData: TaskData?): ButtonActionState = + ButtonActionState( + action = ButtonAction.DROP_PIN, + isEnabled = true, + isVisible = taskData.isNullOrEmpty(), + ) + + override fun onButtonClick(action: ButtonAction) { + if (action == ButtonAction.DROP_PIN) { + dropPin() + } else { + super.onButtonClick(action) + } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt index 972e9bff18..d0c59342c2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskFragment.kt @@ -25,7 +25,9 @@ import javax.inject.Inject import javax.inject.Provider import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.databinding.FragmentDrawAreaTaskBinding @@ -131,6 +133,11 @@ class DrawAreaTaskFragment @Inject constructor() : AbstractTaskFragment drawAreaTaskMapFragment.moveToPosition(coordinates) } + .launchIn(viewLifecycleOwner.lifecycleScope) } override fun onTaskResume() { diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index c9a7abc54d..396b36c2e4 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -20,11 +20,19 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.data.local.LocalValueStore @@ -39,8 +47,11 @@ import org.groundplatform.android.model.settings.MeasurementUnits import org.groundplatform.android.model.submission.DrawAreaTaskData import org.groundplatform.android.model.submission.DrawAreaTaskIncompleteData import org.groundplatform.android.model.submission.TaskData +import org.groundplatform.android.model.submission.isNotNullOrEmpty import org.groundplatform.android.model.task.Task import org.groundplatform.android.ui.common.SharedViewModel +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractMapTaskViewModel import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.util.LocaleAwareMeasureFormatter @@ -97,6 +108,10 @@ internal constructor( */ val draftUpdates = _draftUpdates.asSharedFlow() + /** Channel for one-shot camera movement events. Fragment collects to move map. */ + private val _cameraMoveEvents = Channel(Channel.CONFLATED) + val cameraMoveEvents = _cameraMoveEvents.receiveAsFlow() + /** Whether the instructions dialog has been shown or not. */ var instructionsDialogShown: Boolean by localValueStore::drawAreaInstructionsShown @@ -132,8 +147,37 @@ internal constructor( private lateinit var featureStyle: Feature.Style lateinit var measurementUnits: MeasurementUnits - override fun initialize(job: Job, task: Task, taskData: TaskData?) { - super.initialize(job, task, taskData) + // andreia:re-check this, probably only area flows are needed + override val taskActionButtonStates: StateFlow> by lazy { + combine( + taskTaskData, + merge(draftArea, draftUpdates).filterNotNull().map { + (it?.geometry as? LineString)?.isClosed() ?: false + }, + _isMarkedComplete, + _isTooClose, + ) { taskData, closed, isMarkedComplete, isTooClose -> + listOfNotNull( + getPreviousButtonState(), + getSkipButtonState(taskData), + getUndoButtonState(taskData), + getRedoButtonState(taskData), + getAddPointButtonState(closed, isTooClose), + getCompleteButton(closed, isMarkedComplete, hasSelfIntersection), + getNextButtonState(taskData).takeIf { isMarkedComplete() }, + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + + override fun initialize( + job: Job, + task: Task, + taskData: TaskData?, + isFirstPosition: () -> Boolean, + isLastPosition: (TaskData?) -> Boolean, + ) { + super.initialize(job, task, taskData, isFirstPosition, isLastPosition) viewModelScope.launch { measurementUnits = getUserSettingsUseCase.invoke().measurementUnits } featureStyle = Feature.Style(job.getDefaultColor(), Feature.VertexStyle.CIRCLE) @@ -142,7 +186,6 @@ internal constructor( is DrawAreaTaskIncompleteData -> { updateVertices(taskData.lineString.coordinates) } - is DrawAreaTaskData -> { updateVertices(taskData.area.getShellCoordinates()) try { @@ -388,6 +431,60 @@ internal constructor( vibrationHelper.vibrate() } + private fun getRedoButtonState(taskData: TaskData?): ButtonActionState = + ButtonActionState( + action = ButtonAction.REDO, + isEnabled = redoVertexStack.isNotEmpty() && taskData.isNotNullOrEmpty(), + isVisible = true, + ) + + private fun getAddPointButtonState( + isPolygonClosed: Boolean, + isTooClose: Boolean, + ): ButtonActionState = + ButtonActionState( + action = ButtonAction.ADD_POINT, + isEnabled = !isPolygonClosed && !isTooClose, + isVisible = !isPolygonClosed, + ) + + fun getCompleteButton( + isClosed: Boolean, + isMarkedComplete: Boolean, + hasSelfIntersection: Boolean, + ): ButtonActionState = + ButtonActionState( + action = ButtonAction.COMPLETE, + isEnabled = isClosed && !isMarkedComplete && !hasSelfIntersection, + isVisible = isClosed && !isMarkedComplete, + ) + + override fun onButtonClick(action: ButtonAction) { + when (action) { + ButtonAction.UNDO -> { + removeLastVertex() + getLastVertex()?.let { _cameraMoveEvents.trySend(it) } + } + ButtonAction.REDO -> { + redoLastVertex() + getLastVertex()?.let { _cameraMoveEvents.trySend(it) } + } + ButtonAction.ADD_POINT -> { + addLastVertex() + val intersected = checkVertexIntersection() + if (!intersected) triggerVibration() + } + ButtonAction.COMPLETE -> { + if (validatePolygonCompletion()) { + completePolygon() + } + } + else -> { + super.onButtonClick(action) + } + } + } + companion object { /** Min. distance in dp between two points for them be considered as overlapping. */ const val DISTANCE_THRESHOLD_DP = 24 diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskViewModel.kt index 3b4dbef2e1..fb2a0574d9 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskViewModel.kt @@ -17,18 +17,29 @@ package org.groundplatform.android.ui.datacollection.tasks.text import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.groundplatform.android.R import org.groundplatform.android.common.Constants import org.groundplatform.android.model.submission.TaskData import org.groundplatform.android.model.submission.TextTaskData import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel class TextTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + /** Transcoded text to be displayed for the current [TaskData]. */ val responseText: LiveData = taskTaskData.filterIsInstance().map { it?.text ?: "" }.asLiveData() diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskViewModel.kt index 3bccfc318a..4e874e0bca 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskViewModel.kt @@ -15,12 +15,23 @@ */ package org.groundplatform.android.ui.datacollection.tasks.time +import androidx.lifecycle.viewModelScope import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.groundplatform.android.model.submission.DateTimeTaskData.Companion.fromMillis +import org.groundplatform.android.ui.datacollection.components.refactor.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel class TimeTaskViewModel @Inject constructor() : AbstractTaskViewModel() { + override val taskActionButtonStates: StateFlow> by lazy { + taskTaskData + .map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } fun updateResponse(date: Date) { setValue(fromMillis(date.time))