Skip to content

Commit cfc4501

Browse files
Move logic from StartupFragment to compose screen and view models (#3461)
1 parent 6a168c3 commit cfc4501

File tree

9 files changed

+315
-40
lines changed

9 files changed

+315
-40
lines changed

app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ dependencies {
213213
implementation libs.androidx.ui.tooling.preview.android
214214
stagingImplementation libs.androidx.ui.test.manifest
215215
testImplementation libs.androidx.ui.test.junit4
216+
implementation libs.androidx.navigation.compose
217+
implementation libs.androidx.hilt.navigation.compose
216218

217219
// Images
218220
implementation libs.glide
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.groundplatform.android.ui.components
17+
18+
import androidx.compose.foundation.layout.Row
19+
import androidx.compose.foundation.layout.Spacer
20+
import androidx.compose.foundation.layout.padding
21+
import androidx.compose.foundation.layout.size
22+
import androidx.compose.foundation.layout.width
23+
import androidx.compose.material3.CircularProgressIndicator
24+
import androidx.compose.material3.MaterialTheme
25+
import androidx.compose.material3.Surface
26+
import androidx.compose.material3.Text
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.ui.Alignment
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.res.stringResource
31+
import androidx.compose.ui.tooling.preview.Preview
32+
import androidx.compose.ui.unit.dp
33+
import androidx.compose.ui.window.Dialog
34+
import org.groundplatform.android.R
35+
import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
36+
import org.groundplatform.android.ui.theme.AppTheme
37+
import org.groundplatform.android.ui.theme.sizes
38+
39+
@Composable
40+
fun LoadingDialog(messageId: Int) {
41+
Dialog(onDismissRequest = {}) {
42+
Surface(
43+
shape = MaterialTheme.shapes.medium,
44+
color = MaterialTheme.colorScheme.surface,
45+
tonalElevation = 4.dp,
46+
) {
47+
Row(modifier = Modifier.padding(24.dp), verticalAlignment = Alignment.CenterVertically) {
48+
CircularProgressIndicator(
49+
modifier = Modifier.size(MaterialTheme.sizes.progressIndicatorSize),
50+
color = MaterialTheme.colorScheme.primary,
51+
strokeWidth = MaterialTheme.sizes.progressIndicatorStrokeWidth,
52+
)
53+
Spacer(modifier = Modifier.width(20.dp))
54+
Text(text = stringResource(messageId), style = MaterialTheme.typography.bodyLarge)
55+
}
56+
}
57+
}
58+
}
59+
60+
@Composable
61+
@ExcludeFromJacocoGeneratedReport
62+
@Preview(showBackground = true)
63+
private fun PreviewLoadingDialog() {
64+
AppTheme { LoadingDialog(R.string.loading) }
65+
}

app/src/main/java/org/groundplatform/android/ui/startup/StartupFragment.kt

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,59 +19,35 @@ import android.os.Bundle
1919
import android.view.LayoutInflater
2020
import android.view.View
2121
import android.view.ViewGroup
22-
import androidx.compose.foundation.layout.Box
23-
import androidx.compose.foundation.layout.fillMaxSize
24-
import androidx.compose.ui.Modifier
2522
import androidx.compose.ui.platform.ComposeView
2623
import androidx.compose.ui.platform.ViewCompositionStrategy
27-
import androidx.fragment.app.viewModels
28-
import androidx.lifecycle.lifecycleScope
29-
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
3024
import dagger.hilt.android.AndroidEntryPoint
3125
import javax.inject.Inject
32-
import kotlinx.coroutines.launch
33-
import org.groundplatform.android.R
3426
import org.groundplatform.android.ui.common.AbstractFragment
3527
import org.groundplatform.android.ui.common.EphemeralPopups
36-
import timber.log.Timber
28+
import org.groundplatform.android.ui.theme.AppTheme
3729

3830
@AndroidEntryPoint
3931
class StartupFragment : AbstractFragment() {
4032

4133
@Inject lateinit var popups: EphemeralPopups
4234

43-
private val viewModel: StartupViewModel by viewModels()
44-
4535
override fun onCreateView(
4636
inflater: LayoutInflater,
4737
container: ViewGroup?,
4838
savedInstanceState: Bundle?,
4939
): View =
5040
ComposeView(requireContext()).apply {
5141
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
52-
setContent { Box(modifier = Modifier.fillMaxSize()) }
53-
}
54-
55-
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
56-
super.onViewCreated(view, savedInstanceState)
57-
58-
viewLifecycleOwner.lifecycleScope.launch {
59-
showProgressDialog(R.string.initializing)
60-
try {
61-
viewModel.initializeLogin()
62-
} catch (t: Throwable) {
63-
onInitFailed(t)
64-
} finally {
65-
dismissProgressDialog()
42+
setContent {
43+
AppTheme {
44+
StartupScreen(
45+
onLoadFailed = { errorMessageId ->
46+
errorMessageId?.let { popups.ErrorPopup().show(it) }
47+
requireActivity().finish()
48+
}
49+
)
50+
}
6651
}
6752
}
68-
}
69-
70-
private fun onInitFailed(t: Throwable) {
71-
Timber.e(t, "Failed to launch app")
72-
if (t is GooglePlayServicesNotAvailableException) {
73-
popups.ErrorPopup().show(R.string.google_api_install_failed)
74-
}
75-
requireActivity().finish()
76-
}
7753
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.groundplatform.android.ui.startup
17+
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.fillMaxSize
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.ui.Modifier
23+
import androidx.hilt.navigation.compose.hiltViewModel
24+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+
import org.groundplatform.android.R
26+
import org.groundplatform.android.ui.components.LoadingDialog
27+
28+
/**
29+
* Displays the startup screen and handles initial application setup.
30+
*
31+
* @param onLoadFailed callback invoked when the startup initialization process fails.
32+
* @param viewModel the [StartupViewModel] responsible for managing the startup state.
33+
*/
34+
@Composable
35+
fun StartupScreen(
36+
onLoadFailed: (errorMessageId: Int?) -> Unit,
37+
viewModel: StartupViewModel = hiltViewModel(),
38+
) {
39+
val state by viewModel.state.collectAsStateWithLifecycle()
40+
41+
Box(modifier = Modifier.fillMaxSize()) {
42+
when (state) {
43+
is StartupState.Loading -> LoadingDialog(messageId = R.string.initializing)
44+
is StartupState.Error -> onLoadFailed((state as StartupState.Error).errorMessageId)
45+
}
46+
}
47+
}

app/src/main/java/org/groundplatform/android/ui/startup/StartupViewModel.kt

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,58 @@
1515
*/
1616
package org.groundplatform.android.ui.startup
1717

18+
import androidx.annotation.StringRes
19+
import androidx.lifecycle.viewModelScope
20+
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
1821
import dagger.hilt.android.lifecycle.HiltViewModel
1922
import javax.inject.Inject
23+
import kotlinx.coroutines.flow.MutableStateFlow
24+
import kotlinx.coroutines.flow.StateFlow
25+
import kotlinx.coroutines.flow.asStateFlow
26+
import kotlinx.coroutines.launch
27+
import org.groundplatform.android.R
2028
import org.groundplatform.android.repository.UserRepository
2129
import org.groundplatform.android.system.GoogleApiManager
2230
import org.groundplatform.android.ui.common.AbstractViewModel
31+
import org.groundplatform.android.ui.common.EphemeralPopups
32+
import timber.log.Timber
2333

34+
/** Represents the different states the application can be in during its initial startup flow. */
35+
sealed interface StartupState {
36+
data object Loading : StartupState
37+
38+
data class Error(@StringRes val errorMessageId: Int?) : StartupState
39+
}
40+
41+
/** ViewModel responsible for the initial app startup flow. */
2442
@HiltViewModel
2543
class StartupViewModel
2644
@Inject
2745
internal constructor(
2846
private val googleApiManager: GoogleApiManager,
2947
private val userRepository: UserRepository,
48+
val popups: EphemeralPopups,
3049
) : AbstractViewModel() {
3150

32-
/** Initializes the login flow, installing Google Play Services if necessary. */
33-
suspend fun initializeLogin() {
34-
googleApiManager.installGooglePlayServices()
35-
userRepository.init()
51+
private val _state = MutableStateFlow<StartupState>(StartupState.Loading)
52+
val state: StateFlow<StartupState> = _state.asStateFlow()
53+
54+
init {
55+
viewModelScope.launch {
56+
try {
57+
googleApiManager.installGooglePlayServices()
58+
userRepository.init()
59+
} catch (t: Throwable) {
60+
Timber.e(t, "Failed to launch app")
61+
_state.value = StartupState.Error(getErrorMessageId(t))
62+
}
63+
}
64+
}
65+
66+
private fun getErrorMessageId(throwable: Throwable): Int? {
67+
if (throwable is GooglePlayServicesNotAvailableException) {
68+
return R.string.google_api_install_failed
69+
}
70+
return null
3671
}
3772
}

app/src/main/java/org/groundplatform/android/ui/theme/Size.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ import androidx.compose.runtime.compositionLocalOf
2323
import androidx.compose.ui.unit.Dp
2424
import androidx.compose.ui.unit.dp
2525

26-
@Immutable data class Size(val jobActionButtonSize: Dp = 80.dp, val jobActionIconSize: Dp = 36.dp)
26+
@Immutable
27+
data class Size(
28+
val jobActionButtonSize: Dp = 80.dp,
29+
val jobActionIconSize: Dp = 36.dp,
30+
val progressIndicatorSize: Dp = 24.dp,
31+
val progressIndicatorStrokeWidth: Dp = 2.dp,
32+
)
2733

2834
internal val LocalSizes = compositionLocalOf { Size() }
2935

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.groundplatform.android.ui.startup
17+
18+
import androidx.compose.ui.test.onNodeWithText
19+
import com.google.common.truth.Truth.assertThat
20+
import dagger.hilt.android.testing.HiltAndroidTest
21+
import kotlinx.coroutines.flow.MutableStateFlow
22+
import org.groundplatform.android.BaseHiltTest
23+
import org.groundplatform.android.R
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
import org.mockito.Mock
27+
import org.mockito.kotlin.whenever
28+
import org.robolectric.RobolectricTestRunner
29+
import org.robolectric.RuntimeEnvironment
30+
31+
@HiltAndroidTest
32+
@RunWith(RobolectricTestRunner::class)
33+
class StartupScreenTest : BaseHiltTest() {
34+
35+
@Mock private lateinit var mockViewModel: StartupViewModel
36+
37+
@Test
38+
fun `Loading state shows loading dialog`() {
39+
setState(StartupState.Loading)
40+
41+
composeTestRule.setContent { StartupScreen(onLoadFailed = {}, viewModel = mockViewModel) }
42+
43+
val loadingText = RuntimeEnvironment.getApplication().getString(R.string.initializing)
44+
composeTestRule.onNodeWithText(loadingText).assertExists()
45+
}
46+
47+
@Test
48+
fun `Error state invokes onLoadFailed`() {
49+
var onLoadFailedCalled = false
50+
setState(StartupState.Error(null))
51+
52+
composeTestRule.setContent {
53+
StartupScreen(onLoadFailed = { onLoadFailedCalled = true }, viewModel = mockViewModel)
54+
}
55+
56+
assertThat(onLoadFailedCalled).isTrue()
57+
}
58+
59+
private fun setState(state: StartupState) {
60+
whenever(mockViewModel.state).thenReturn(MutableStateFlow(state))
61+
}
62+
}

0 commit comments

Comments
 (0)