Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ dependencies {
implementation libs.androidx.ui.tooling.preview.android
stagingImplementation libs.androidx.ui.test.manifest
testImplementation libs.androidx.ui.test.junit4
implementation libs.androidx.navigation.compose
implementation libs.androidx.hilt.navigation.compose

// Images
implementation libs.glide
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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.components

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.android.ui.theme.AppTheme
import org.groundplatform.android.ui.theme.sizes

@Composable
fun LoadingDialog(messageId: Int) {
Dialog(onDismissRequest = {}) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp,
) {
Row(modifier = Modifier.padding(24.dp), verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(MaterialTheme.sizes.progressIndicatorSize),
color = MaterialTheme.colorScheme.primary,
strokeWidth = MaterialTheme.sizes.progressIndicatorStrokeWidth,
)
Spacer(modifier = Modifier.width(20.dp))
Text(text = stringResource(messageId), style = MaterialTheme.typography.bodyLarge)
}
}
}
}

@Composable
@ExcludeFromJacocoGeneratedReport
@Preview(showBackground = true)
private fun PreviewLoadingDialog() {
AppTheme { LoadingDialog(R.string.loading) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,59 +19,35 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.EphemeralPopups
import timber.log.Timber
import org.groundplatform.android.ui.theme.AppTheme

@AndroidEntryPoint
class StartupFragment : AbstractFragment() {

@Inject lateinit var popups: EphemeralPopups

private val viewModel: StartupViewModel by viewModels()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View =
ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { Box(modifier = Modifier.fillMaxSize()) }
}

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

viewLifecycleOwner.lifecycleScope.launch {
showProgressDialog(R.string.initializing)
try {
viewModel.initializeLogin()
} catch (t: Throwable) {
onInitFailed(t)
} finally {
dismissProgressDialog()
setContent {
AppTheme {
StartupScreen(
onLoadFailed = { errorMessageId ->
errorMessageId?.let { popups.ErrorPopup().show(it) }
requireActivity().finish()
}
)
}
}
}
}

private fun onInitFailed(t: Throwable) {
Timber.e(t, "Failed to launch app")
if (t is GooglePlayServicesNotAvailableException) {
popups.ErrorPopup().show(R.string.google_api_install_failed)
}
requireActivity().finish()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.startup

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.groundplatform.android.R
import org.groundplatform.android.ui.components.LoadingDialog

/**
* Displays the startup screen and handles initial application setup.
*
* @param onLoadFailed callback invoked when the startup initialization process fails.
* @param viewModel the [StartupViewModel] responsible for managing the startup state.
*/
@Composable
fun StartupScreen(
onLoadFailed: (errorMessageId: Int?) -> Unit,
viewModel: StartupViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()

Box(modifier = Modifier.fillMaxSize()) {
when (state) {
is StartupState.Loading -> LoadingDialog(messageId = R.string.initializing)
is StartupState.Error -> onLoadFailed((state as StartupState.Error).errorMessageId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,58 @@
*/
package org.groundplatform.android.ui.startup

import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.groundplatform.android.R
import org.groundplatform.android.repository.UserRepository
import org.groundplatform.android.system.GoogleApiManager
import org.groundplatform.android.ui.common.AbstractViewModel
import org.groundplatform.android.ui.common.EphemeralPopups
import timber.log.Timber

/** Represents the different states the application can be in during its initial startup flow. */
sealed interface StartupState {
data object Loading : StartupState

data class Error(@StringRes val errorMessageId: Int?) : StartupState
}

/** ViewModel responsible for the initial app startup flow. */
@HiltViewModel
class StartupViewModel
@Inject
internal constructor(
private val googleApiManager: GoogleApiManager,
private val userRepository: UserRepository,
val popups: EphemeralPopups,
) : AbstractViewModel() {

/** Initializes the login flow, installing Google Play Services if necessary. */
suspend fun initializeLogin() {
googleApiManager.installGooglePlayServices()
userRepository.init()
private val _state = MutableStateFlow<StartupState>(StartupState.Loading)
val state: StateFlow<StartupState> = _state.asStateFlow()

init {
viewModelScope.launch {
try {
googleApiManager.installGooglePlayServices()
userRepository.init()
} catch (t: Throwable) {
Timber.e(t, "Failed to launch app")
_state.value = StartupState.Error(getErrorMessageId(t))
}
}
}

private fun getErrorMessageId(throwable: Throwable): Int? {
if (throwable is GooglePlayServicesNotAvailableException) {
return R.string.google_api_install_failed
}
return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Immutable data class Size(val jobActionButtonSize: Dp = 80.dp, val jobActionIconSize: Dp = 36.dp)
@Immutable
data class Size(
val jobActionButtonSize: Dp = 80.dp,
val jobActionIconSize: Dp = 36.dp,
val progressIndicatorSize: Dp = 24.dp,
val progressIndicatorStrokeWidth: Dp = 2.dp,
)

internal val LocalSizes = compositionLocalOf { Size() }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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.startup

import androidx.compose.ui.test.onNodeWithText
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.MutableStateFlow
import org.groundplatform.android.BaseHiltTest
import org.groundplatform.android.R
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
class StartupScreenTest : BaseHiltTest() {

@Mock private lateinit var mockViewModel: StartupViewModel

@Test
fun `Loading state shows loading dialog`() {
setState(StartupState.Loading)

composeTestRule.setContent { StartupScreen(onLoadFailed = {}, viewModel = mockViewModel) }

val loadingText = RuntimeEnvironment.getApplication().getString(R.string.initializing)
composeTestRule.onNodeWithText(loadingText).assertExists()
}

@Test
fun `Error state invokes onLoadFailed`() {
var onLoadFailedCalled = false
setState(StartupState.Error(null))

composeTestRule.setContent {
StartupScreen(onLoadFailed = { onLoadFailedCalled = true }, viewModel = mockViewModel)
}

assertThat(onLoadFailedCalled).isTrue()
}

private fun setState(state: StartupState) {
whenever(mockViewModel.state).thenReturn(MutableStateFlow(state))
}
}
Loading