Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
714304f
Extract common logic for hosting task map fragments into a new Compos…
shobhitagarwal1612 Mar 27, 2026
60e6bfd
Remove unused resource
shobhitagarwal1612 Mar 27, 2026
c19470d
Rename DateTaskScreen to DateInputField
shobhitagarwal1612 Mar 27, 2026
f074b40
Rename `PhotoTaskScreen` to `PhotoTaskContent`
shobhitagarwal1612 Mar 27, 2026
0752b51
Rename task screen components and files to more descriptive names.
shobhitagarwal1612 Mar 27, 2026
1d36a3e
Introduce `TaskScreen` Composable for data collection tasks
shobhitagarwal1612 Mar 27, 2026
127c562
Extract TaskScreen state into a dedicated state holder class
shobhitagarwal1612 Mar 27, 2026
358825e
Remove setLoiName function from TaskScreenState
shobhitagarwal1612 Mar 27, 2026
4bd575f
Refactor loiName state assignment in TaskScreen
shobhitagarwal1612 Mar 27, 2026
98991ef
Refactor TaskScreenState to use remember instead of rememberSaveable
shobhitagarwal1612 Mar 28, 2026
e4e71c5
Apply suggestions. Make TaskScreen stateless
shobhitagarwal1612 Mar 30, 2026
d249c5a
Add basic test coverage
shobhitagarwal1612 Mar 30, 2026
6e8c479
Improve test coverage
shobhitagarwal1612 Mar 30, 2026
9ac0cab
Fix tests
shobhitagarwal1612 Mar 30, 2026
3d8c64e
Move `TaskScreenAction` to its own file
shobhitagarwal1612 Mar 30, 2026
79386da
Fix detekt issue
shobhitagarwal1612 Mar 30, 2026
3858a16
Merge branch 'master' into tasks-compose
shobhitagarwal1612 Mar 30, 2026
d5a769f
Fix IllegalStateException that was causing crash
shobhitagarwal1612 Mar 30, 2026
39350dd
Update detekt baseline
shobhitagarwal1612 Mar 30, 2026
5d9fff3
Add coverage for onFooterPositionUpdated
shobhitagarwal1612 Mar 30, 2026
90989ba
Improve test coverage using negative states
shobhitagarwal1612 Mar 30, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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

import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import javax.inject.Provider
import org.groundplatform.android.ui.common.AbstractMapContainerFragment
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY

/**
* A Composable that hosts a fragment-based map container for a specific task.
*
* This function bridges the gap between Jetpack Compose and the Fragment-based map implementation
* by using [AndroidView] to embed a [FragmentContainerView].
*/
@Composable
fun TaskMapFragmentContainer(
taskId: String,
fragmentManager: FragmentManager,
fragmentProvider: Provider<out AbstractMapContainerFragment>,
) {
AndroidView(
factory = { context -> FragmentContainerView(context).apply { id = View.generateViewId() } },
update = { view ->
with(fragmentProvider.get()) {
arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId))
fragmentManager.beginTransaction().replace(view.id, this).commit()
}
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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.tasks

import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import org.groundplatform.android.ui.datacollection.components.ButtonActionState
import org.groundplatform.android.ui.datacollection.components.InstructionData
import org.groundplatform.android.ui.datacollection.components.InstructionsDialog
import org.groundplatform.android.ui.datacollection.components.LoiNameDialog
import org.groundplatform.android.ui.datacollection.components.TaskFooter
import org.groundplatform.android.ui.datacollection.components.TaskHeader
import org.groundplatform.android.ui.datacollection.components.TaskViewLayout

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TaskScreen(
taskHeader: TaskHeader?,
instructionData: InstructionData?,
taskActionButtonsStates: List<ButtonActionState>,
shouldShowLoiNameDialog: Boolean,
shouldShowHeader: Boolean,
showInstructionsDialog: Boolean,
loiName: String,
onFooterPositionUpdated: (Float) -> Unit,
onAction: (TaskScreenAction) -> Unit,
headerCard: @Composable (() -> Unit)?,
taskBody: @Composable () -> Unit,
) {
val isKeyboardOpen = WindowInsets.isImeVisible
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }

// Update footer position whenever layout changes or keyboard is toggled.
LaunchedEffect(isKeyboardOpen, layoutCoordinates) {
layoutCoordinates?.let { onFooterPositionUpdated(it.positionInWindow().y) }
}

TaskViewLayout(
header = taskHeader,
footer = {
TaskFooter(
modifier = Modifier.onGloballyPositioned { layoutCoordinates = it },
headerCard = headerCard.takeIf { shouldShowHeader },
buttonActionStates = taskActionButtonsStates,
onButtonClicked = { onAction(TaskScreenAction.OnButtonClicked(it)) },
)
},
content = { taskBody() },
)

if (shouldShowLoiNameDialog) {
LoiNameDialog(
textFieldValue = loiName,
onConfirmRequest = { onAction(TaskScreenAction.OnLoiNameConfirm(loiName)) },
onDismissRequest = { onAction(TaskScreenAction.OnLoiNameDismiss) },
onTextFieldChange = { onAction(TaskScreenAction.OnLoiNameChanged(it)) },
)
}

instructionData
?.takeIf { showInstructionsDialog }
?.let {
InstructionsDialog(
data = it,
onDismissed = { onAction(TaskScreenAction.OnInstructionsDismiss) },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.tasks

import org.groundplatform.android.ui.datacollection.components.ButtonAction

/**
* Defines the set of actions that can be triggered from a task-specific screen in the data
* collection flow.
*/
sealed interface TaskScreenAction {
data class OnButtonClicked(val action: ButtonAction) : TaskScreenAction

data class OnLoiNameConfirm(val name: String) : TaskScreenAction

data object OnLoiNameDismiss : TaskScreenAction

data class OnLoiNameChanged(val name: String) : TaskScreenAction

data object OnInstructionsDismiss : TaskScreenAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const val DATE_TEXT_TEST_TAG: String = "date task input test tag"
// TODO: Add trailing icon (close logo) for clearing selected date.

@Composable
fun DateTaskScreen(
fun DateInputField(
dateText: String,
hintText: String,
onDateClick: () -> Unit,
Expand Down Expand Up @@ -72,16 +72,16 @@ fun DateTaskScreen(
@Preview(showBackground = true)
@Composable
@ExcludeFromJacocoGeneratedReport
private fun DateTaskScreenPreview() {
private fun DateInputFieldPreview() {
AppTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DateTaskScreen(dateText = "", hintText = "DD/MM/YYYY", onDateClick = {})
DateInputField(dateText = "", hintText = "DD/MM/YYYY", onDateClick = {})
Spacer(modifier = Modifier.height(10.dp))
DateTaskScreen(dateText = "14/02/2026", hintText = "DD/MM/YYYY", onDateClick = {})
DateInputField(dateText = "14/02/2026", hintText = "DD/MM/YYYY", onDateClick = {})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class DateTaskFragment : AbstractTaskFragment<DateTaskViewModel>() {
(DateFormat.getDateFormat(context) as SimpleDateFormat).toPattern().uppercase()
}

DateTaskScreen(
DateInputField(
modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding),
dateText = dateText,
hintText = hintText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ package org.groundplatform.android.ui.datacollection.tasks.location
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.view.View
import android.widget.LinearLayout
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -30,8 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
Expand All @@ -43,8 +39,8 @@ import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.ui.components.ConfirmationDialog
import org.groundplatform.android.ui.datacollection.components.TaskHeader
import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY
import org.groundplatform.android.ui.datacollection.tasks.LocationLockEnabledState

@AndroidEntryPoint
Expand All @@ -61,21 +57,10 @@ class CaptureLocationTaskFragment @Inject constructor() :
override fun TaskBody() {
var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog

AndroidView(
factory = { context ->
// NOTE(#2493): Multiplying by a random prime to allow for some mathematical uniqueness.
// Otherwise, the sequentially generated ID might conflict with an ID produced by Google
// Maps.
LinearLayout(context).apply {
id = View.generateViewId() * 11149
val fragment = captureLocationTaskMapFragmentProvider.get()
fragment.arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId))
childFragmentManager
.beginTransaction()
.add(id, fragment, CaptureLocationTaskMapFragment::class.java.simpleName)
.commit()
}
}
TaskMapFragmentContainer(
taskId = viewModel.task.id,
fragmentManager = childFragmentManager,
fragmentProvider = captureLocationTaskMapFragmentProvider,
)

if (showPermissionDeniedDialog) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import org.groundplatform.android.ui.datacollection.components.UriImage
import org.groundplatform.ui.theme.AppTheme

@Composable
fun PhotoTaskScreen(uri: Uri, onTakePhoto: () -> Unit, modifier: Modifier = Modifier) {
fun PhotoTaskContent(uri: Uri, onTakePhoto: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
if (uri == Uri.EMPTY) {
CaptureButton(onTakePhoto)
Expand Down Expand Up @@ -68,15 +68,15 @@ private fun CaptureButton(onTakePhoto: () -> Unit) {
@Preview(showBackground = true)
@Composable
@ExcludeFromJacocoGeneratedReport
private fun PhotoTaskScreenPreviewEmpty() {
AppTheme { PhotoTaskScreen(uri = Uri.EMPTY, onTakePhoto = {}) }
private fun PhotoTaskContentPreviewEmpty() {
AppTheme { PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}) }
}

@Preview(showBackground = true)
@Composable
@ExcludeFromJacocoGeneratedReport
private fun PhotoTaskScreenPreviewWithPhoto() {
AppTheme {
PhotoTaskScreen(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {})
PhotoTaskContent(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class PhotoTaskFragment : AbstractTaskFragment<PhotoTaskViewModel>() {
var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog
val uri by viewModel.uri.collectAsStateWithLifecycle(Uri.EMPTY)

PhotoTaskScreen(
PhotoTaskContent(
modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding),
uri = uri,
onTakePhoto = { onTakePhoto() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,15 @@
*/
package org.groundplatform.android.ui.datacollection.tasks.point

import android.view.View
import android.widget.LinearLayout
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import javax.inject.Provider
import org.groundplatform.android.R
import org.groundplatform.android.ui.datacollection.components.InstructionData
import org.groundplatform.android.ui.datacollection.components.TaskHeader
import org.groundplatform.android.ui.datacollection.components.TaskMapFragmentContainer
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment.Companion.TASK_ID_FRAGMENT_ARG_KEY

@AndroidEntryPoint
class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment<DropPinTaskViewModel>() {
Expand All @@ -42,18 +38,10 @@ class DropPinTaskFragment @Inject constructor() : AbstractTaskFragment<DropPinTa

@Composable
override fun TaskBody() {
AndroidView(
factory = { context ->
// NOTE(#2493): Multiplying by a random prime to allow for some mathematical "uniqueness".
// Otherwise, the sequentially generated ID might conflict with an ID produced by Google
// Maps.
LinearLayout(context).apply {
id = View.generateViewId() * 11617
val fragment = dropPinTaskMapFragmentProvider.get()
fragment.arguments = bundleOf(Pair(TASK_ID_FRAGMENT_ARG_KEY, taskId))
childFragmentManager.beginTransaction().add(id, fragment, "Drop a pin fragment").commit()
}
}
TaskMapFragmentContainer(
taskId = viewModel.task.id,
fragmentManager = childFragmentManager,
fragmentProvider = dropPinTaskMapFragmentProvider,
)
}

Expand Down
Loading
Loading