Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
92daef8
Replace ViewPager2 and task fragments with Compose-based TaskScreenCo…
shobhitagarwal1612 May 13, 2026
ea14afe
Replace custom TaskMapFragmentContainer with official AndroidFragment
shobhitagarwal1612 May 13, 2026
9bb7b73
Move map fragment instantiation into individual task screens
shobhitagarwal1612 May 13, 2026
527696d
Replace Fragment parameter with explicit callbacks in DataCollectionS…
shobhitagarwal1612 May 13, 2026
bb1edf4
Refactor OpenSettings and SetAwaitingPhotoCapture to use DataCollecti…
shobhitagarwal1612 May 13, 2026
e187aa9
Remove onButtonClicked parameter from TaskScreens and handle clicks v…
shobhitagarwal1612 May 13, 2026
dc299de
Refactor TaskScreenContainer to use hoisted state and move to tasks p…
shobhitagarwal1612 May 13, 2026
df95c27
Fix failing tests
shobhitagarwal1612 May 13, 2026
195036c
Remove MutableSharedFlow and simplify event handling in DataCollectio…
shobhitagarwal1612 May 13, 2026
aa103c9
Cleanup unused imports and params
shobhitagarwal1612 May 13, 2026
4b7ec09
Merge branch 'master' into datacollection-pager
shobhitagarwal1612 May 13, 2026
aab7cda
Merge branch 'master' into datacollection-pager
shobhitagarwal1612 May 14, 2026
7a39332
Apply suggested changes
shobhitagarwal1612 May 14, 2026
a4d9193
Use absoluteIndex instead of relativeIndex for task selection in Data…
shobhitagarwal1612 May 14, 2026
07c4ec7
Optimize draft loading by centralizing task data initialization and a…
shobhitagarwal1612 May 16, 2026
0b1f426
Merge branch 'master' into datacollection-pager
shobhitagarwal1612 May 16, 2026
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
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ dependencies {
}

// Fragments
implementation libs.androidx.fragment.compose
debugImplementation libs.androidx.fragment.testing.manifest

// TODO: Move protos into shared module and set correct path here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,22 @@ import org.groundplatform.android.R
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.BackPressListener
import org.groundplatform.android.ui.common.EphemeralPopups
import org.groundplatform.android.ui.home.HomeScreenViewModel
import org.groundplatform.android.util.createComposeView
import org.groundplatform.android.util.openAppSettings
import javax.inject.Inject

/** Fragment allowing the user to collect data to complete a task. */
@AndroidEntryPoint
class DataCollectionFragment : AbstractFragment(), BackPressListener {
@Inject lateinit var popups: EphemeralPopups
@Inject lateinit var viewPagerAdapterFactory: DataCollectionViewPagerAdapterFactory

val viewModel: DataCollectionViewModel by hiltNavGraphViewModels(R.id.data_collection)

val homeScreenViewModel: HomeScreenViewModel by lazy {
getViewModel(HomeScreenViewModel::class.java)
}

private var isNavigatingUp = false

override fun onCreateView(
Expand All @@ -46,9 +51,10 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener {
): View = createComposeView {
DataCollectionScreen(
viewModel = viewModel,
fragment = this,
onValidationError = { resId -> popups.ErrorPopup().show(resId) },
onExitConfirmed = { navigateBack() },
onOpenSettings = { requireActivity().openAppSettings() },
onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it },
)
}

Expand Down Expand Up @@ -84,4 +90,8 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener {
viewModel.clearDraftBlocking()
findNavController().navigateUp()
}

companion object {
const val TASK_ID: String = "taskId"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
Expand All @@ -41,20 +42,24 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.groundplatform.android.R
import org.groundplatform.android.ui.components.ConfirmationDialog
import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer

/**
* The main screen for data collection, coordinating the task sequence and host UI.
*
* @param viewModel The view model for data collection.
* @param fragment The fragment hosting this screen (retained for ViewPager2 adapter creation).
* @param onValidationError Callback when a validation error occurs.
* @param onExitConfirmed Callback when the user confirms exiting the data collection flow.
* @param onOpenSettings Callback to open the app settings.
* @param onAwaitingPhotoCapture Callback to set whether the app is awaiting a photo capture.
*/
@Composable
fun DataCollectionScreen(
viewModel: DataCollectionViewModel,
fragment: DataCollectionFragment,
onValidationError: (resId: Int) -> Unit,
onExitConfirmed: () -> Unit,
onOpenSettings: () -> Unit,
onAwaitingPhotoCapture: (Boolean) -> Unit,
) {
val showExitWarningDialog by viewModel.showExitWarning.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Expand All @@ -63,13 +68,40 @@ fun DataCollectionScreen(
viewModel.uiEffects.collect { effect ->
when (effect) {
is DataCollectionUiEffect.Exit -> onExitConfirmed()
is DataCollectionUiEffect.OpenSettings -> onOpenSettings()
is DataCollectionUiEffect.SetAwaitingPhotoCapture -> onAwaitingPhotoCapture(effect.awaiting)
is DataCollectionUiEffect.ShowValidationError -> onValidationError(effect.errorResId)
}
}
}

DataCollectionContent(uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }) {
DataCollectionViewPager(uiState, fragment)
readyState ->
val tasks = readyState.tasks
if (tasks.isNotEmpty()) {
val position = readyState.position
val currentTask = tasks[position.absoluteIndex]

key(currentTask.id) {
viewModel.getTaskViewModel(currentTask.id)?.let { taskViewModel ->
val loiName by viewModel.loiNameDraft.collectAsStateWithLifecycle()
val showLoiNameDialog by viewModel.loiNameDialogOpen.collectAsStateWithLifecycle()

val onLoiNameAction = { action: LoiNameAction ->
viewModel.handleLoiNameAction(action, currentTask.id)
}

TaskScreenContainer(
task = currentTask,
taskViewModel = taskViewModel,
taskPosition = position,
loiName = loiName,
shouldShowLoiNameDialog = showLoiNameDialog,
onLoiNameAction = { onLoiNameAction(it) },
)
}
}
}
}

if (showExitWarningDialog) {
Expand Down Expand Up @@ -102,7 +134,7 @@ object DataCollectionScreenTestTags {
fun DataCollectionContent(
uiState: DataCollectionUiState,
onCloseClicked: () -> Unit,
pagerContent: @Composable () -> Unit,
pagerContent: @Composable (DataCollectionUiState.Ready) -> Unit,
) {
Scaffold(topBar = { DataCollectionToolbar(uiState, onCloseClicked) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
Expand All @@ -118,7 +150,7 @@ fun DataCollectionContent(
ErrorContent()
}
is DataCollectionUiState.Ready -> {
ReadyContent(pagerContent = pagerContent)
ReadyContent { pagerContent(uiState) }
}
is DataCollectionUiState.TaskSubmitted -> {
DataSubmissionConfirmationScreen(loiReport = uiState.loiReport) { onCloseClicked() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ package org.groundplatform.android.ui.datacollection
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand Down Expand Up @@ -60,11 +60,14 @@ import org.groundplatform.domain.repository.SubmissionRepositoryInterface
import org.groundplatform.domain.usecases.GetLoiReportUseCase
import org.groundplatform.domain.usecases.submission.SubmitDataUseCase
import timber.log.Timber
import javax.inject.Inject

sealed interface DataCollectionUiEffect {
data object Exit : DataCollectionUiEffect

data object OpenSettings : DataCollectionUiEffect

data class SetAwaitingPhotoCapture(val awaiting: Boolean) : DataCollectionUiEffect

data class ShowValidationError(val errorResId: Int) : DataCollectionUiEffect
}

Expand All @@ -88,9 +91,6 @@ internal constructor(
private val _uiEffects = Channel<DataCollectionUiEffect>(Channel.BUFFERED)
val uiEffects = _uiEffects.receiveAsFlow()

private val _dataCollectionEvents =
MutableSharedFlow<DataCollectionEvent>(extraBufferCapacity = 1)

private val _uiState = MutableStateFlow<DataCollectionUiState>(DataCollectionUiState.Loading)
val uiState: StateFlow<DataCollectionUiState> = _uiState

Expand All @@ -112,10 +112,7 @@ internal constructor(
private lateinit var taskSequenceHandler: TaskSequenceHandler
private val taskViewModels = MutableStateFlow(mutableMapOf<String, AbstractTaskViewModel>())

private val draftLock = Any()
@Volatile private var draftCache: List<ValueDelta>? = null
@Volatile private var draftMapCache: Map<Pair<String, Task.Type>, TaskData?>? = null
@Volatile private var draftsEnabled = true
private var draftsEnabled = true

init {
viewModelScope.launch {
Expand All @@ -128,6 +125,9 @@ internal constructor(
)

if (initResult is DataCollectionUiState.Ready) {
if (shouldLoadFromDraft) {
initializeDraftValues(initResult.job, initResult.tasks)
}
taskSequenceHandler = TaskSequenceHandler(initResult.tasks, taskDataHandler)
}

Expand All @@ -136,19 +136,6 @@ internal constructor(
}
_uiState.value = initResult
}

viewModelScope.launch {
_dataCollectionEvents.collect { event ->
withReadyOrNull { it.currentTaskId }
?.let { taskId ->
when (event) {
DataCollectionEvent.NavigatePrevious -> onPreviousClicked(taskId)
DataCollectionEvent.NavigateNext -> onNextClicked(taskId)
DataCollectionEvent.ShowLoiDialog -> openLoiNameDialog()
}
}
}
}
}

private fun setLoiName(name: String) {
Expand Down Expand Up @@ -203,6 +190,16 @@ internal constructor(
viewModelScope.launch { _uiEffects.send(DataCollectionUiEffect.Exit) }
}

private fun openSettings() {
viewModelScope.launch { _uiEffects.send(DataCollectionUiEffect.OpenSettings) }
}

private fun setAwaitingPhotoCapture(awaiting: Boolean) {
viewModelScope.launch {
_uiEffects.send(DataCollectionUiEffect.SetAwaitingPhotoCapture(awaiting))
}
}

fun handleLoiNameAction(action: LoiNameAction, taskId: String) {
when (action) {
is LoiNameAction.Confirmed -> {
Expand Down Expand Up @@ -319,11 +316,10 @@ internal constructor(
}

viewModel?.let { created ->
val taskData = if (shouldLoadFromDraft) getValueFromDraft(state.job, task) else null
created.initialize(
job = state.job,
task = task,
taskData = taskData,
taskData = taskDataHandler.getData(task),
taskPositionInterface =
object : TaskPositionInterface {
override fun isFirst(): Boolean = isFirstPosition(task.id)
Expand All @@ -332,9 +328,20 @@ internal constructor(
isLastPositionWithValue(task, taskData)
},
surveyId = state.surveyId,
eventReporter = { _dataCollectionEvents.tryEmit(it) },
eventReporter = { event ->
withReadyOrNull { it.currentTaskId }
?.let { taskId ->
when (event) {
is DataCollectionEvent.NavigatePrevious -> onPreviousClicked(taskId)
is DataCollectionEvent.NavigateNext -> onNextClicked(taskId)
is DataCollectionEvent.ShowLoiDialog -> openLoiNameDialog()
is DataCollectionEvent.OpenSettings -> openSettings()
is DataCollectionEvent.SetAwaitingPhotoCapture ->
setAwaitingPhotoCapture(event.awaiting)
}
}
},
)
updateDataAndInvalidateTasks(task, taskData)
taskViewModels.value[task.id] = created
}
viewModel
Expand Down Expand Up @@ -430,18 +437,10 @@ internal constructor(
return block(s)
}

private fun ensureDraftCaches(job: Job) {
if (!shouldLoadFromDraft || draftCache != null) return

val serialized: String = savedStateHandle[TASK_DRAFT_VALUES] ?: ""
if (serialized.isEmpty()) {
private fun initializeDraftValues(job: Job, tasks: List<Task>) {
val serialized: String? = savedStateHandle[TASK_DRAFT_VALUES]
if (serialized.isNullOrBlank()) {
Timber.w("No draft values found; skipping load")
synchronized(draftLock) {
if (draftCache == null) {
draftCache = emptyList()
draftMapCache = emptyMap()
}
}
return
}

Expand All @@ -453,23 +452,16 @@ internal constructor(
emptyList()
}

synchronized(draftLock) {
if (draftCache == null) {
draftCache = parsed
draftMapCache = parsed.associate { (taskId, taskType, value) ->
(taskId to taskType) to value
}
}
}
}
val deltaMap = parsed.associateBy { it.taskId to it.taskType }

val draftValues =
tasks
.mapNotNull { task -> deltaMap[task.id to task.type]?.newTaskData?.let { task to it } }
.toMap()

private fun getValueFromDraft(job: Job, task: Task): TaskData? {
if (!shouldLoadFromDraft) return null
ensureDraftCaches(job)
val value = draftMapCache?.get(task.id to task.type)
if (value == null) Timber.w("Value not found for task $task")
else Timber.d("Value $value found for task $task")
return value
if (draftValues.isNotEmpty()) {
taskDataHandler.setData(draftValues)
}
}

private inline fun validateOrShow(taskVm: AbstractTaskViewModel, onValid: () -> Unit) {
Expand Down

This file was deleted.

Loading
Loading