Skip to content
Open
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
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ dependencies {

// Google Play Services
implementation libs.play.services.auth
implementation libs.play.services.code.scanner
implementation libs.play.services.location
implementation libs.play.services.maps

Expand Down
11 changes: 8 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />

<!-- Pre-installs the ML Kit barcode scanner module so the first QR scan has no download delay. -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />

<activity
android:name="org.groundplatform.android.ui.main.MainActivity"
android:exported="true"
Expand All @@ -78,9 +83,9 @@
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="groundplatform.org"
android:pathPrefix="/android/survey/" />
android:host="@string/deeplink_host"
android:pathPrefix="@string/survey_deeplink_path"
android:scheme="https" />
</intent-filter>
</activity>
<activity
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
*/
package org.groundplatform.android.di

import android.content.res.Resources
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.groundplatform.android.R
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
import org.groundplatform.domain.repository.SubmissionRepositoryInterface
import org.groundplatform.domain.repository.SurveyRepositoryInterface
Expand All @@ -28,6 +30,7 @@ import org.groundplatform.domain.usecases.submission.SubmitDataUseCase
import org.groundplatform.domain.usecases.survey.SyncSurveyUseCase
import org.groundplatform.domain.usecases.user.GetUserSettingsUseCase
import org.groundplatform.domain.usecases.user.UpdateUserSettingsUseCase
import org.groundplatform.domain.util.SurveyQrCodeParser

@InstallIn(SingletonComponent::class)
@Module
Expand Down Expand Up @@ -56,4 +59,11 @@ object UseCaseModule {
loiRepository: LocationOfInterestRepositoryInterface,
submissionRepository: SubmissionRepositoryInterface,
) = SubmitDataUseCase(loiRepository, submissionRepository)

@Provides
fun providesSurveyQrCodeParser(resources: Resources) =
SurveyQrCodeParser(
deepLinkHost = resources.getString(R.string.deeplink_host),
deepLinkPath = resources.getString(R.string.survey_deeplink_path),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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.system

import android.content.Context
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber

@Singleton
class GmsQrCodeScanner @Inject constructor(@ApplicationContext private val context: Context) {

suspend fun scan(): Result = suspendCancellableCoroutine { coroutine ->
val options =
GmsBarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
GmsBarcodeScanning.getClient(context, options)
.startScan()
.addOnSuccessListener { barcode ->
Timber.d("Scanned QR code with raw value: ${barcode.rawValue}")
coroutine.resume(barcode.rawValue?.let(Result::Success) ?: Result.Cancelled)
}
.addOnCanceledListener {
Timber.d("QR code scan cancelled by user")
coroutine.resume(Result.Cancelled)
}
.addOnFailureListener { e ->
Timber.e(e, "QR code scan failed with exception")
coroutine.resume(Result.Error(e))
}
}

sealed interface Result {
/** The scanner returned a decoded payload. */
data class Success(val text: String) : Result

/** The user dismissed the scanner without a successful scan. */
data object Cancelled : Result

/** The scan failed (camera unavailable, module install failure, etc.). */
data class Error(val cause: Throwable) : Result
}
}
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.surveyselector

sealed interface SurveySelectorEvent {
object NavigateToHome : SurveySelectorEvent

data class ShowError(val errorType: ErrorType) : SurveySelectorEvent

/** Errors surfaced from the Survey Selector screen for the host to display. */
sealed interface ErrorType {
/** Survey loading or activation timed out. */
data object Timeout : ErrorType

/** A generic error encountered while loading or activating a survey. */
data class Generic(val cause: Throwable) : ErrorType

/** The scanned QR code did not encode a valid survey link. */
data object InvalidQrCode : ErrorType
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.TimeoutCancellationException
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.BackPressListener
Expand Down Expand Up @@ -53,10 +52,13 @@ class SurveySelectorFragment : AbstractFragment(), BackPressListener {
findNavController().navigate(HomeScreenFragmentDirections.showHomeScreen())
},
onError = { error ->
if (error is TimeoutCancellationException) {
ephemeralPopups.ErrorPopup().show(R.string.survey_load_timeout_error)
} else {
ephemeralPopups.ErrorPopup().unknownError()
when (error) {
SurveySelectorEvent.ErrorType.Timeout ->
ephemeralPopups.ErrorPopup().show(R.string.survey_load_timeout_error)
is SurveySelectorEvent.ErrorType.Generic ->
ephemeralPopups.ErrorPopup().unknownError()
SurveySelectorEvent.ErrorType.InvalidQrCode ->
ephemeralPopups.ErrorPopup().show(R.string.invalid_survey_qr_code)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,32 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.flowWithLifecycle
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.android.ui.components.LoadingDialog
import org.groundplatform.android.ui.components.Toolbar
import org.groundplatform.android.ui.surveyselector.components.SurveyEmptyState
import org.groundplatform.android.ui.surveyselector.components.SurveySectionList
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.SurveyListItem
import org.groundplatform.ui.theme.AppTheme

/**
* Stateful composable that handles ViewModel interactions and side effects for the Survey Selector
Expand All @@ -46,17 +60,17 @@ import org.groundplatform.android.ui.surveyselector.components.SurveySectionList
fun SurveySelectorScreen(
onBack: () -> Unit,
onNavigateToHomeScreen: () -> Unit,
onError: (Throwable) -> Unit,
onError: (SurveySelectorEvent.ErrorType) -> Unit,
viewModel: SurveySelectorViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// Collect and handle one-shot events in a LaunchedEffect
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle) {
viewModel.events.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { event ->
when (event) {
is SurveySelectorEvent.NavigateToHome -> onNavigateToHomeScreen()
is SurveySelectorEvent.ShowError -> onError(event.error)
is SurveySelectorEvent.ShowError -> onError(event.errorType)
}
}
}
Expand All @@ -67,6 +81,7 @@ fun SurveySelectorScreen(
onSignOut = viewModel::signOut,
onConfirmDelete = viewModel::confirmDelete,
onCardClick = viewModel::activateSurvey,
onScanQrCode = viewModel::scanQrCodeAndActivateSurvey,
)
}

Expand All @@ -78,6 +93,7 @@ fun SurveySelectorScreen(
* @param onSignOut Callback when the user attempts to sign out from the empty state.
* @param onConfirmDelete Callback when a local survey deletion is confirmed.
* @param onCardClick Callback when a survey card is clicked to activate it.
* @param onScanQrCode Callback when the user taps the scan-QR action.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -87,11 +103,27 @@ private fun SurveySelectorScreen(
onSignOut: () -> Unit,
onConfirmDelete: (String) -> Unit,
onCardClick: (String) -> Unit,
onScanQrCode: () -> Unit,
) {
Scaffold(
topBar = {
Toolbar(stringRes = R.string.surveys, showNavigationIcon = true, iconClick = onBack)
}
},
floatingActionButton = {
if (!uiState.isLoading) {
ExtendedFloatingActionButton(
onClick = onScanQrCode,
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_qr_code_scanner),
contentDescription = null,
tint = Color.Black,
)
},
text = { Text(stringResource(R.string.join_survey)) },
)
}
},
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
when {
Expand All @@ -113,3 +145,70 @@ private fun SurveySelectorScreen(
}
}
}

@Composable
@Preview(showBackground = true, showSystemUi = true)
@ExcludeFromJacocoGeneratedReport
private fun PreviewSurveySelectorScreenEmpty() {
AppTheme {
SurveySelectorScreen(
uiState = SurveySelectorUiState(),
onBack = {},
onSignOut = {},
onConfirmDelete = {},
onCardClick = {},
onScanQrCode = {},
)
}
}

@Composable
@Preview(showBackground = true, showSystemUi = true)
@ExcludeFromJacocoGeneratedReport
private fun PreviewSurveySelectorScreenWithSurveys() {
val dummySurveys =
listOf(
SurveyListItem("1", "Tree Survey", "Track tree growth", true, Survey.GeneralAccess.PUBLIC),
SurveyListItem(
"2",
"Water Survey",
"Check water quality",
false,
Survey.GeneralAccess.RESTRICTED,
),
)

val uiState =
SurveySelectorUiState(
onDeviceSurveys = dummySurveys,
sharedSurveys = emptyList(),
publicSurveys = dummySurveys,
)

AppTheme {
SurveySelectorScreen(
uiState = uiState,
onBack = {},
onSignOut = {},
onConfirmDelete = {},
onCardClick = {},
onScanQrCode = {},
)
}
}

@Composable
@Preview(showBackground = true, showSystemUi = true)
@ExcludeFromJacocoGeneratedReport
private fun PreviewSurveySelectorScreenLoading() {
AppTheme {
SurveySelectorScreen(
uiState = SurveySelectorUiState(isLoading = true),
onBack = {},
onSignOut = {},
onConfirmDelete = {},
onCardClick = {},
onScanQrCode = {},
)
}
}
Loading
Loading