From 485d691f322353555feaae827450437cd3f24912 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 17:36:14 +0530 Subject: [PATCH 01/12] Add Compose Navigation and Hilt Compose Navigation dependencies --- app/build.gradle | 2 ++ gradle/libs.versions.toml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) 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/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" } From 1cb6e40f374b546f7a3310e1dc1d9c137a7e2a6d Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 17:36:40 +0530 Subject: [PATCH 02/12] Add reusable `LoadingDialog` component This will be used in StartupFragment for showing progress dialog using compose --- .../android/ui/components/LoadingDialog.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt 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..7e84a780ee --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt @@ -0,0 +1,64 @@ +/* + * 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 + +@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(36.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + ) + 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) } +} From 9530ca109b70e94fe867633d9777c9c49da4c4ba Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 17:37:56 +0530 Subject: [PATCH 03/12] Refactor startup flow to use Compose - Move startup initialization logic from `StartupFragment` to a new `StartupScreen` Composable. - Migrate error handling and progress dialog management from `StartupFragment` to `StartupViewModel` and `StartupScreen`. - Update `StartupViewModel` and `SignInViewModel` to use `@HiltViewModel` and remove manual bindings in `ViewModelModule`. --- .../android/ui/common/ViewModelModule.kt | 6 --- .../android/ui/signin/SignInViewModel.kt | 2 + .../android/ui/startup/StartupFragment.kt | 41 +---------------- .../android/ui/startup/StartupScreen.kt | 46 +++++++++++++++++++ .../android/ui/startup/StartupViewModel.kt | 14 +++++- 5 files changed, 63 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt index c7bc9571a6..003ab7158e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt +++ b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt @@ -40,7 +40,6 @@ import org.groundplatform.android.ui.offlineareas.OfflineAreasViewModel import org.groundplatform.android.ui.offlineareas.selector.OfflineAreaSelectorViewModel import org.groundplatform.android.ui.offlineareas.viewer.OfflineAreaViewerViewModel import org.groundplatform.android.ui.settings.SettingsViewModel -import org.groundplatform.android.ui.signin.SignInViewModel import org.groundplatform.android.ui.surveyselector.SurveySelectorViewModel import org.groundplatform.android.ui.syncstatus.SyncStatusViewModel import org.groundplatform.android.ui.tos.TermsOfServiceViewModel @@ -83,11 +82,6 @@ abstract class ViewModelModule { @ViewModelKey(MainViewModel::class) abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(SignInViewModel::class) - abstract fun bindSignInVideModel(viewModel: SignInViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(TermsOfServiceViewModel::class) diff --git a/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt index 828d84c51a..b9e599eb7c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.ui.signin import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -30,6 +31,7 @@ import org.groundplatform.android.system.NetworkStatus import org.groundplatform.android.system.auth.SignInState import org.groundplatform.android.ui.common.AbstractViewModel +@HiltViewModel class SignInViewModel @Inject internal constructor( 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..552e9376ee 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,15 @@ 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 +35,6 @@ 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() - } - } - } - - 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) + setContent { AppTheme { StartupScreen(onLoadFailed = { requireActivity().finish() }) } } } - 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..9e258b7361 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -0,0 +1,46 @@ +/* + * 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.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +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: () -> Unit, viewModel: StartupViewModel = hiltViewModel()) { + + Box(modifier = Modifier.fillMaxSize()) { LoadingDialog(messageId = R.string.initializing) } + + LaunchedEffect(Unit) { + try { + viewModel.initializeLogin() + } catch (t: Throwable) { + viewModel.maybeDisplayError(t) + onLoadFailed() + } + } +} 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..b3eef1968b 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,11 +15,15 @@ */ package org.groundplatform.android.ui.startup +import com.google.android.gms.common.GooglePlayServicesNotAvailableException import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +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 +import javax.inject.Inject @HiltViewModel class StartupViewModel @@ -27,6 +31,7 @@ class StartupViewModel internal constructor( private val googleApiManager: GoogleApiManager, private val userRepository: UserRepository, + val popups: EphemeralPopups, ) : AbstractViewModel() { /** Initializes the login flow, installing Google Play Services if necessary. */ @@ -34,4 +39,11 @@ internal constructor( googleApiManager.installGooglePlayServices() userRepository.init() } + + fun maybeDisplayError(throwable: Throwable) { + Timber.e(throwable, "Failed to launch app") + if (throwable is GooglePlayServicesNotAvailableException) { + popups.ErrorPopup().show(R.string.google_api_install_failed) + } + } } From 7c8320d6cf73378ad70e4afe85ffa69aaad29415 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 17:41:37 +0530 Subject: [PATCH 04/12] Add KDoc comments --- .../org/groundplatform/android/ui/startup/StartupViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) 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 b3eef1968b..dfe485aa1c 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 @@ -25,6 +25,7 @@ import org.groundplatform.android.ui.common.EphemeralPopups import timber.log.Timber import javax.inject.Inject +/** ViewModel responsible for the initial app startup flow. */ @HiltViewModel class StartupViewModel @Inject @@ -40,6 +41,7 @@ internal constructor( userRepository.init() } + /** Displays an error popup. */ fun maybeDisplayError(throwable: Throwable) { Timber.e(throwable, "Failed to launch app") if (throwable is GooglePlayServicesNotAvailableException) { From 3a481b5068c1c1bb317d160f9ef20753d9fd1d5b Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 17:56:48 +0530 Subject: [PATCH 05/12] Refactor app startup flow to use UI state --- .../android/ui/startup/StartupScreen.kt | 15 ++++---- .../android/ui/startup/StartupViewModel.kt | 37 +++++++++++++++---- 2 files changed, 36 insertions(+), 16 deletions(-) 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 index 9e258b7361..9b51acb78d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize 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.lifecycle.compose.collectAsStateWithLifecycle import org.groundplatform.android.R import org.groundplatform.android.ui.components.LoadingDialog @@ -32,15 +34,12 @@ import org.groundplatform.android.ui.components.LoadingDialog */ @Composable fun StartupScreen(onLoadFailed: () -> Unit, viewModel: StartupViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() - Box(modifier = Modifier.fillMaxSize()) { LoadingDialog(messageId = R.string.initializing) } - - LaunchedEffect(Unit) { - try { - viewModel.initializeLogin() - } catch (t: Throwable) { - viewModel.maybeDisplayError(t) - onLoadFailed() + Box(modifier = Modifier.fillMaxSize()) { + when (state) { + is StartupState.Loading -> LoadingDialog(messageId = R.string.initializing) + is StartupState.Error -> LaunchedEffect(Unit) { onLoadFailed() } } } } 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 dfe485aa1c..eae2be7780 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,15 +15,27 @@ */ package org.groundplatform.android.ui.startup +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 -import javax.inject.Inject + +/** Represents the different states the application can be in during its initial startup flow. */ +sealed interface StartupState { + data object Loading : StartupState + + data class Error(val throwable: Throwable) : StartupState +} /** ViewModel responsible for the initial app startup flow. */ @HiltViewModel @@ -35,15 +47,24 @@ internal constructor( 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") + maybeDisplayError(t) + _state.value = StartupState.Error(t) + } + } } - /** Displays an error popup. */ - fun maybeDisplayError(throwable: Throwable) { - Timber.e(throwable, "Failed to launch app") + /** Displays an error popup if the error is related to Google Play Services. */ + private fun maybeDisplayError(throwable: Throwable) { if (throwable is GooglePlayServicesNotAvailableException) { popups.ErrorPopup().show(R.string.google_api_install_failed) } From b23f205a376cf71b1f42f6ad0a007321e62ed807 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 18:01:27 +0530 Subject: [PATCH 06/12] Undo unrelated changes --- .../org/groundplatform/android/ui/common/ViewModelModule.kt | 6 ++++++ .../org/groundplatform/android/ui/signin/SignInViewModel.kt | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt index 003ab7158e..c7bc9571a6 100644 --- a/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt +++ b/app/src/main/java/org/groundplatform/android/ui/common/ViewModelModule.kt @@ -40,6 +40,7 @@ import org.groundplatform.android.ui.offlineareas.OfflineAreasViewModel import org.groundplatform.android.ui.offlineareas.selector.OfflineAreaSelectorViewModel import org.groundplatform.android.ui.offlineareas.viewer.OfflineAreaViewerViewModel import org.groundplatform.android.ui.settings.SettingsViewModel +import org.groundplatform.android.ui.signin.SignInViewModel import org.groundplatform.android.ui.surveyselector.SurveySelectorViewModel import org.groundplatform.android.ui.syncstatus.SyncStatusViewModel import org.groundplatform.android.ui.tos.TermsOfServiceViewModel @@ -82,6 +83,11 @@ abstract class ViewModelModule { @ViewModelKey(MainViewModel::class) abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(SignInViewModel::class) + abstract fun bindSignInVideModel(viewModel: SignInViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(TermsOfServiceViewModel::class) diff --git a/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt index b9e599eb7c..828d84c51a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/signin/SignInViewModel.kt @@ -16,7 +16,6 @@ package org.groundplatform.android.ui.signin import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -31,7 +30,6 @@ import org.groundplatform.android.system.NetworkStatus import org.groundplatform.android.system.auth.SignInState import org.groundplatform.android.ui.common.AbstractViewModel -@HiltViewModel class SignInViewModel @Inject internal constructor( From 825942015ee1b5de98bb01d690d302768db15ec5 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 20:34:47 +0530 Subject: [PATCH 07/12] Remove `LaunchedEffect` from `StartupState.Error` in `StartupScreen` The change removes an unnecessary `LaunchedEffect` wrapper around the `onLoadFailed` callback when the `StartupScreen` is in an error state. --- .../org/groundplatform/android/ui/startup/StartupScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 9b51acb78d..f486ed85ae 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -18,7 +18,6 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -39,7 +38,7 @@ fun StartupScreen(onLoadFailed: () -> Unit, viewModel: StartupViewModel = hiltVi Box(modifier = Modifier.fillMaxSize()) { when (state) { is StartupState.Loading -> LoadingDialog(messageId = R.string.initializing) - is StartupState.Error -> LaunchedEffect(Unit) { onLoadFailed() } + is StartupState.Error -> onLoadFailed() } } } From 586286d1d24186fa90a2b1ea175b40f9143cc408 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 21:10:18 +0530 Subject: [PATCH 08/12] Add unit tests for StartupScreen --- .../android/ui/startup/StartupScreenTest.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt 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..f71409ebeb --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt @@ -0,0 +1,63 @@ +/* + * 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(Error("Loading error"))) + + composeTestRule.setContent { + StartupScreen(onLoadFailed = { onLoadFailedCalled = true }, viewModel = mockViewModel) + } + + assertThat(onLoadFailedCalled).isTrue() + } + + private fun setState(state: StartupState) { + whenever(mockViewModel.state).thenReturn(MutableStateFlow(state)) + } +} From 9612c32992fa5c18098bc121aaf5c29bffd95a41 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 21:38:07 +0530 Subject: [PATCH 09/12] Add unit tests for `StartupViewModel` --- .../ui/startup/StartupViewModelTest.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt 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..305203071f --- /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).throwable).isEqualTo(exception) + } + } + + @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).throwable).isEqualTo(exception) + } + } +} From 1b0b9208ed26bc1661058d36e637910a71c30734 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Sun, 18 Jan 2026 21:38:50 +0530 Subject: [PATCH 10/12] Fix formatting --- .../org/groundplatform/android/ui/startup/StartupScreen.kt | 2 +- .../org/groundplatform/android/ui/startup/StartupScreenTest.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 index f486ed85ae..b96ad14b67 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -38,7 +38,7 @@ fun StartupScreen(onLoadFailed: () -> Unit, viewModel: StartupViewModel = hiltVi Box(modifier = Modifier.fillMaxSize()) { when (state) { is StartupState.Loading -> LoadingDialog(messageId = R.string.initializing) - is StartupState.Error -> onLoadFailed() + is StartupState.Error -> onLoadFailed() } } } 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 index f71409ebeb..f46d41ec68 100644 --- a/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt @@ -32,8 +32,7 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class StartupScreenTest : BaseHiltTest() { - @Mock - private lateinit var mockViewModel: StartupViewModel + @Mock private lateinit var mockViewModel: StartupViewModel @Test fun `Loading state shows loading dialog`() { From 4592b987b7200c640307e692e2f73b1d5ad3c9e3 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 19 Jan 2026 18:14:24 +0530 Subject: [PATCH 11/12] Add size and strokeWidth to Size.kt theme class. --- .../groundplatform/android/ui/components/LoadingDialog.kt | 5 +++-- .../main/java/org/groundplatform/android/ui/theme/Size.kt | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) 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 index 7e84a780ee..69660243eb 100644 --- a/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt +++ b/app/src/main/java/org/groundplatform/android/ui/components/LoadingDialog.kt @@ -34,6 +34,7 @@ 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) { @@ -45,9 +46,9 @@ fun LoadingDialog(messageId: Int) { ) { Row(modifier = Modifier.padding(24.dp), verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator( - modifier = Modifier.size(36.dp), + modifier = Modifier.size(MaterialTheme.sizes.progressIndicatorSize), color = MaterialTheme.colorScheme.primary, - strokeWidth = 4.dp, + strokeWidth = MaterialTheme.sizes.progressIndicatorStrokeWidth, ) Spacer(modifier = Modifier.width(20.dp)) Text(text = stringResource(messageId), style = MaterialTheme.typography.bodyLarge) 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() } From e7679a4185d131cf8c136ba85106093802ee4d00 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Mon, 19 Jan 2026 18:28:50 +0530 Subject: [PATCH 12/12] Move error popup logic from `StartupViewModel` to `StartupFragment` --- .../android/ui/startup/StartupFragment.kt | 15 ++++++++++++++- .../android/ui/startup/StartupScreen.kt | 7 +++++-- .../android/ui/startup/StartupViewModel.kt | 12 ++++++------ .../android/ui/startup/StartupScreenTest.kt | 2 +- .../android/ui/startup/StartupViewModelTest.kt | 4 ++-- 5 files changed, 28 insertions(+), 12 deletions(-) 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 552e9376ee..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 @@ -22,12 +22,16 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.groundplatform.android.ui.common.AbstractFragment +import org.groundplatform.android.ui.common.EphemeralPopups import org.groundplatform.android.ui.theme.AppTheme @AndroidEntryPoint class StartupFragment : AbstractFragment() { + @Inject lateinit var popups: EphemeralPopups + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -35,6 +39,15 @@ class StartupFragment : AbstractFragment() { ): View = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { AppTheme { StartupScreen(onLoadFailed = { requireActivity().finish() }) } } + setContent { + AppTheme { + StartupScreen( + onLoadFailed = { errorMessageId -> + errorMessageId?.let { popups.ErrorPopup().show(it) } + 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 index b96ad14b67..11b1fc4007 100644 --- a/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/startup/StartupScreen.kt @@ -32,13 +32,16 @@ import org.groundplatform.android.ui.components.LoadingDialog * @param viewModel the [StartupViewModel] responsible for managing the startup state. */ @Composable -fun StartupScreen(onLoadFailed: () -> Unit, viewModel: StartupViewModel = hiltViewModel()) { +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() + 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 eae2be7780..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,6 +15,7 @@ */ 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 @@ -34,7 +35,7 @@ import timber.log.Timber sealed interface StartupState { data object Loading : StartupState - data class Error(val throwable: Throwable) : StartupState + data class Error(@StringRes val errorMessageId: Int?) : StartupState } /** ViewModel responsible for the initial app startup flow. */ @@ -57,16 +58,15 @@ internal constructor( userRepository.init() } catch (t: Throwable) { Timber.e(t, "Failed to launch app") - maybeDisplayError(t) - _state.value = StartupState.Error(t) + _state.value = StartupState.Error(getErrorMessageId(t)) } } } - /** Displays an error popup if the error is related to Google Play Services. */ - private fun maybeDisplayError(throwable: Throwable) { + private fun getErrorMessageId(throwable: Throwable): Int? { if (throwable is GooglePlayServicesNotAvailableException) { - popups.ErrorPopup().show(R.string.google_api_install_failed) + return R.string.google_api_install_failed } + return null } } 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 index f46d41ec68..21187394bb 100644 --- a/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupScreenTest.kt @@ -47,7 +47,7 @@ class StartupScreenTest : BaseHiltTest() { @Test fun `Error state invokes onLoadFailed`() { var onLoadFailedCalled = false - setState(StartupState.Error(Error("Loading error"))) + setState(StartupState.Error(null)) composeTestRule.setContent { StartupScreen(onLoadFailed = { onLoadFailedCalled = true }, viewModel = mockViewModel) 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 index 305203071f..e844316eed 100644 --- a/app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/startup/StartupViewModelTest.kt @@ -60,7 +60,7 @@ class StartupViewModelTest : BaseHiltTest() { viewModel.state.test { val errorState = awaitItem() assertThat(errorState).isInstanceOf(StartupState.Error::class.java) - assertThat((errorState as StartupState.Error).throwable).isEqualTo(exception) + assertThat((errorState as StartupState.Error).errorMessageId).isNull() } } @@ -74,7 +74,7 @@ class StartupViewModelTest : BaseHiltTest() { viewModel.state.test { val errorState = awaitItem() assertThat(errorState).isInstanceOf(StartupState.Error::class.java) - assertThat((errorState as StartupState.Error).throwable).isEqualTo(exception) + assertThat((errorState as StartupState.Error).errorMessageId).isNull() } } }