diff --git a/app/build.gradle b/app/build.gradle index b6c4bef9dc..b23806d207 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt b/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt new file mode 100644 index 0000000000..69660243eb --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt @@ -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) } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/startup/StartupFragment.kt b/app/src/main/java/org/groundplatform/android/ui/startup/StartupFragment.kt index 3dba891313..a8283b38ae 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupFragment.kt @@ -19,29 +19,19 @@ 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?, @@ -49,29 +39,15 @@ class StartupFragment : AbstractFragment() { ): 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() - } } diff --git a/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt new file mode 100644 index 0000000000..11b1fc4007 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/startup/StartupViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/startup/StartupViewModel.kt index 5654e27485..dac021c575 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupViewModel.kt @@ -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.Loading) + val state: StateFlow = _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 } } diff --git a/app/src/main/java/org/groundplatform/android/ui/theme/Size.kt b/app/src/main/java/org/groundplatform/android/ui/theme/Size.kt index 76e159a70e..8b6706b0fa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/theme/Size.kt +++ b/app/src/main/java/org/groundplatform/android/ui/theme/Size.kt @@ -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() } diff --git a/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt new file mode 100644 index 0000000000..21187394bb --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt new file mode 100644 index 0000000000..e844316eed --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt @@ -0,0 +1,80 @@ +/* + * 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 app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.repository.UserRepository +import org.groundplatform.android.system.GoogleApiManager +import org.groundplatform.android.ui.common.EphemeralPopups +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class StartupViewModelTest : BaseHiltTest() { + + @Mock private lateinit var googleApiManager: GoogleApiManager + @Mock private lateinit var userRepository: UserRepository + @Mock private lateinit var popups: EphemeralPopups + + private lateinit var viewModel: StartupViewModel + + @Test + fun `init success`() = runWithTestDispatcher { + viewModel = StartupViewModel(googleApiManager, userRepository, popups) + + viewModel.state.test { + assertThat(awaitItem()).isEqualTo(StartupState.Loading) + expectNoEvents() + } + } + + @Test + fun `init fails from GoogleApiManager with other exception`() = runWithTestDispatcher { + val exception = RuntimeException("some other error") + whenever(googleApiManager.installGooglePlayServices()).thenThrow(exception) + + viewModel = StartupViewModel(googleApiManager, userRepository, popups) + + viewModel.state.test { + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(StartupState.Error::class.java) + assertThat((errorState as StartupState.Error).errorMessageId).isNull() + } + } + + @Test + fun `init fails from UserRepository`() = runWithTestDispatcher { + val exception = RuntimeException("user repo error") + whenever(userRepository.init()).thenThrow(exception) + + viewModel = StartupViewModel(googleApiManager, userRepository, popups) + + viewModel.state.test { + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(StartupState.Error::class.java) + assertThat((errorState as StartupState.Error).errorMessageId).isNull() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dacb453a06..925389e808 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,6 +82,7 @@ androidx-core-testing = { module = "androidx.arch.core:core-testing", version.re androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" } androidx-fragment-testing-manifest = { module = "androidx.fragment:fragment-testing-manifest", version.ref = "fragmentVersion" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltJetpackVersion" } androidx-hilt-navigation-fragment = { module = "androidx.hilt:hilt-navigation-fragment", version.ref = "hiltJetpackVersion" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltJetpackVersion" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitKtx" } @@ -89,6 +90,7 @@ androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtim androidx-material-icons = { module = "androidx.compose.material:material-icons-core" } androidx-material3-android = { module = "androidx.compose.material3:material3-android" } androidx-multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationKtxVersion" } androidx-navigation-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigationKtxVersion" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationKtxVersion" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationKtxVersion" } @@ -179,4 +181,4 @@ ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmtVersion" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektVersion" } protobuf = { id = "com.google.protobuf", version.ref = "protobufVersion" } -room = { id = "androidx.room", version.ref = "roomVersion" } \ No newline at end of file +room = { id = "androidx.room", version.ref = "roomVersion" }