Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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 = {})
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,15 @@ abstract class AbstractTaskFragment<T : AbstractTaskViewModel> : 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(
Expand All @@ -258,6 +259,33 @@ abstract class AbstractTaskFragment<T : AbstractTaskViewModel> : 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) },
)
Comment on lines +268 to +271
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the fully qualified name for TaskButton makes the code a bit harder to read. It's better to add an import for org.groundplatform.android.ui.datacollection.components.refactor.TaskButton at the top of the file and use the class name directly here.

          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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskData?> = MutableStateFlow(null)
val taskTaskData: StateFlow<TaskData?> = _taskDataFlow.asStateFlow()

abstract val taskActionButtonStates: StateFlow<List<ButtonActionState>>

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)
}

Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<ButtonActionState>> by lazy {
taskTaskData
.map { listOf(getPreviousButtonState(), getSkipButtonState(it), getNextButtonState(it)) }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}

fun updateResponse(date: Date) {
setValue(fromMillis(date.time))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<ButtonActionState>> by lazy {
taskTaskData
.map { listOf(getPreviousButtonState(), getNextButtonState(it)) }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
}
Loading
Loading