Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fa90fc8
Refactor Photo task to use Compose-based `TaskScreen` layout
shobhitagarwal1612 Apr 3, 2026
5a9562b
Get rid of onTaskViewAttached() by passing surveyId to initialize method
shobhitagarwal1612 Apr 3, 2026
2630ef5
Refactor photo task to manage camera and permission logic within the …
shobhitagarwal1612 Apr 3, 2026
049317b
refactor: migrate photo capture awaiting state from event flow to Sta…
shobhitagarwal1612 Apr 3, 2026
d779f81
refactor: replace camera launch flag with state flow and improve phot…
shobhitagarwal1612 Apr 3, 2026
bcbbccc
Improve user-visible error messages
shobhitagarwal1612 Apr 3, 2026
725b0a5
Create enums for errors and add previews
shobhitagarwal1612 Apr 3, 2026
7682f09
refactor: remove redundant taskWaitingForPhoto state and simplify pho…
shobhitagarwal1612 Apr 3, 2026
e689736
Remove redundant call to save image file
shobhitagarwal1612 Apr 3, 2026
61b4513
Update tests
shobhitagarwal1612 Apr 3, 2026
28f0e0f
Refactor PhotoTaskViewModel and PhotoTaskScreen to improve event hand…
shobhitagarwal1612 Apr 3, 2026
228c6a2
Fix code lint issues
shobhitagarwal1612 Apr 3, 2026
7f2b63a
Fix string lint issues
shobhitagarwal1612 Apr 3, 2026
eda1337
Merge branch 'master' into photo-task-compose-2
shobhitagarwal1612 Apr 6, 2026
efef286
Merge branch 'master' into photo-task-compose-2
shobhitagarwal1612 Apr 7, 2026
1457e60
Apply suggested changes
shobhitagarwal1612 Apr 7, 2026
5cab977
Don't show error dialog if user canceled photo capture action
shobhitagarwal1612 Apr 7, 2026
cb7b0a8
Fix detekt issues
shobhitagarwal1612 Apr 7, 2026
2f901c7
Remove obsolete test as we are not showing error when user cancels photo
shobhitagarwal1612 Apr 7, 2026
654ac0d
Merge branch 'master' into photo-task-compose-2
andreia-ferreira Apr 9, 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
Expand Up @@ -220,6 +220,7 @@ internal constructor(
override fun isLastWithValue(taskData: TaskData?): Boolean =
isLastPositionWithValue(task, taskData)
},
surveyId = state.surveyId,
)
updateDataAndInvalidateTasks(task, taskData)
taskViewModels.value[task.id] = created
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.core.view.doOnAttach
import androidx.hilt.navigation.fragment.hiltNavGraphViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlin.properties.Delegates
Expand Down Expand Up @@ -99,11 +98,6 @@ abstract class AbstractTaskFragment<T : AbstractTaskViewModel> : AbstractFragmen
instructionData?.let { InstructionsDialog(it) }
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.doOnAttach { onTaskViewAttached() }
}

override fun onResume() {
super.onResume()
onTaskResume()
Expand All @@ -119,9 +113,6 @@ abstract class AbstractTaskFragment<T : AbstractTaskViewModel> : AbstractFragmen
/** Invoked when the instruction dialog is dismissed. */
open fun onInstructionDialogDismissed() {}

/** Invoked after the task view gets attached to the fragment. */
open fun onTaskViewAttached() {}

/** Invoked when the task fragment is visible to the user. */
open fun onTaskResume() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel(
.stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())
}

lateinit var surveyId: String
lateinit var task: Task
private lateinit var taskPositionInterface: TaskPositionInterface

Expand All @@ -60,7 +61,9 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel(
task: Task,
taskData: TaskData?,
taskPositionInterface: TaskPositionInterface,
surveyId: String,
) {
this.surveyId = surveyId
this.task = task
this.taskPositionInterface = taskPositionInterface
setValue(taskData)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ class MultipleChoiceTaskViewModel @Inject constructor() : AbstractTaskViewModel(
task: Task,
taskData: TaskData?,
taskPositionInterface: TaskPositionInterface,
surveyId: String,
) {
super.initialize(job, task, taskData, taskPositionInterface)
super.initialize(job, task, taskData, taskPositionInterface, surveyId)
loadPendingSelections()
updateMultipleChoiceItems()
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.photo

import android.net.Uri

sealed interface PhotoTaskEvent {
data class LaunchCamera(val uri: Uri) : PhotoTaskEvent

data class ShowError(val errorType: PhotoTaskError) : PhotoTaskEvent
}

enum class PhotoTaskError {
PERMISSION_DENIED,
CAMERA_LAUNCH_FAILED,
PHOTO_SAVE_FAILED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,145 +15,32 @@
*/
package org.groundplatform.android.ui.datacollection.tasks.photo

import android.Manifest
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import android.view.LayoutInflater
import android.view.ViewGroup
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.di.coroutines.ApplicationScope
import org.groundplatform.android.di.coroutines.IoDispatcher
import org.groundplatform.android.di.coroutines.MainScope
import org.groundplatform.android.repository.UserMediaRepository
import org.groundplatform.android.system.PermissionDeniedException
import org.groundplatform.android.system.PermissionsManager
import org.groundplatform.android.ui.common.EphemeralPopups
import org.groundplatform.android.ui.components.ConfirmationDialog
import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment
import org.groundplatform.android.ui.home.HomeScreenViewModel
import org.groundplatform.ui.theme.sizes
import timber.log.Timber
import org.groundplatform.android.util.createComposeView

/** Fragment allowing the user to capture a photo to complete a task. */
@AndroidEntryPoint
class PhotoTaskFragment : AbstractTaskFragment<PhotoTaskViewModel>() {
@Inject lateinit var userMediaRepository: UserMediaRepository
@Inject @ApplicationScope lateinit var externalScope: CoroutineScope
@Inject @MainScope lateinit var mainScope: CoroutineScope
@Inject lateinit var permissionsManager: PermissionsManager
@Inject lateinit var popups: EphemeralPopups
@Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher

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

// Registers a callback to execute after a user captures a photo from the on-device camera.
private lateinit var capturePhotoLauncher: ActivityResultLauncher<Uri>

private var hasRequestedPermissionsOnResume = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

capturePhotoLauncher =
registerForActivityResult(ActivityResultContracts.TakePicture()) { result: Boolean ->
externalScope.launch(ioDispatcher) { viewModel.onCaptureResult(result) }
}
}

@Composable
override fun TaskBody() {
var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog
val uri by viewModel.uri.collectAsStateWithLifecycle(Uri.EMPTY)

PhotoTaskContent(
modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding),
uri = uri,
onTakePhoto = { onTakePhoto() },
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = createComposeView {
PhotoTaskScreen(
viewModel = viewModel,
onFooterPositionUpdated = { saveFooterPosition(it) },
onAction = { handleTaskScreenAction(it) },
onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it },
)

if (showPermissionDeniedDialog) {
ConfirmationDialog(
title = R.string.permission_denied,
description = R.string.camera_permissions_needed,
confirmButtonText = R.string.ok,
onConfirmClicked = { showPermissionDeniedDialog = false },
)
}
}

override fun onTaskViewAttached() {
viewModel.surveyId = dataCollectionViewModel.requireSurveyId()
}

override fun onResume() {
super.onResume()

if (!hasRequestedPermissionsOnResume) {
obtainCapturePhotoPermissions()
hasRequestedPermissionsOnResume = true
}
}

// Requests camera/photo access permissions from the device, executing an optional callback
// when permission is granted.
private fun obtainCapturePhotoPermissions(onPermissionsGranted: () -> Unit = {}) {
lifecycleScope.launch {
try {

// From Android 11 onwards (api level 30), requesting WRITE_EXTERNAL_STORAGE permission
// always returns denied. By default, the app has read/write access to shared data.
//
// For more details please refer to:
// https://developer.android.com/about/versions/11/privacy/storage#permissions-target-11
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
permissionsManager.obtainPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}

permissionsManager.obtainPermission(Manifest.permission.CAMERA)

onPermissionsGranted()
} catch (_: PermissionDeniedException) {
viewModel.showPermissionDeniedDialog.value = true
}
}
}

fun onTakePhoto() {
if (viewModel.hasLaunchedCamera) return

// Keep track of the fact that we are restoring the application after a photo capture.
homeScreenViewModel.awaitingPhotoCapture = true
obtainCapturePhotoPermissions { lifecycleScope.launch { launchPhotoCapture() } }
}

private suspend fun launchPhotoCapture() {
try {
viewModel.waitForPhotoCapture(viewModel.task.id)
val uri = viewModel.createImageFileUri()
viewModel.capturedUri = uri
viewModel.hasLaunchedCamera = true
capturePhotoLauncher.launch(uri)
Timber.d("Capture photo intent sent")
} catch (e: IllegalArgumentException) {
homeScreenViewModel.awaitingPhotoCapture = false
popups.ErrorPopup().unknownError()
Timber.e(e)
}
}
}
Loading
Loading