diff --git a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt index 70593fb..1f2f3ce 100644 --- a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt +++ b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt @@ -78,11 +78,13 @@ class RocketsViewModel @Inject constructor( }, ) - private fun refreshRockets(): Flow = flow { + private fun refreshRockets(): Flow = flow { refreshRocketsUseCase() .onFailure { emit(Error(it)) } + }.onStart { + emit(Loading) } private fun rocketClicked(uri: String): Flow { diff --git a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsListContent.kt b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsListContent.kt index 0d15696..65b9e10 100644 --- a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsListContent.kt +++ b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsListContent.kt @@ -3,7 +3,7 @@ package eu.krzdabrowski.starter.basicfeature.presentation.composable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -35,7 +35,7 @@ fun RocketsListContent( ) if (index < rocketList.lastIndex) { - Divider( + HorizontalDivider( modifier = Modifier.testTag(ROCKET_DIVIDER_TEST_TAG), ) } diff --git a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt index 66b50b4..125c0b8 100644 --- a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt +++ b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt @@ -1,20 +1,24 @@ package eu.krzdabrowski.starter.basicfeature.presentation.composable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import eu.krzdabrowski.starter.basicfeature.R import eu.krzdabrowski.starter.basicfeature.presentation.RocketsEvent import eu.krzdabrowski.starter.basicfeature.presentation.RocketsEvent.OpenWebBrowserWithDetails @@ -45,16 +49,21 @@ internal fun RocketsScreen( onIntent: (RocketsIntent) -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } + val pullToRefreshState = rememberPullToRefreshState() + + HandlePullToRefresh( + pullState = pullToRefreshState, + uiState = uiState, + onIntent = onIntent, + ) Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { - // TODO: migrate from accompanist to built-in pull-to-refresh when added to Material3 - SwipeRefresh( - state = rememberSwipeRefreshState(uiState.isLoading), - onRefresh = { onIntent(RefreshRockets) }, + ) { paddingValues -> + Box( modifier = Modifier - .padding(it), + .padding(paddingValues) + .nestedScroll(pullToRefreshState.nestedScrollConnection), ) { if (uiState.rockets.isNotEmpty()) { RocketsAvailableContent( @@ -67,6 +76,31 @@ internal fun RocketsScreen( uiState = uiState, ) } + + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier + .align(Alignment.TopCenter), + ) + } + } +} + +@Composable +private fun HandlePullToRefresh( + pullState: PullToRefreshState, + uiState: RocketsUiState, + onIntent: (RocketsIntent) -> Unit, +) { + if (pullState.isRefreshing) { + LaunchedEffect(true) { + onIntent(RefreshRockets) + } + } + + if (uiState.isLoading.not()) { + LaunchedEffect(true) { + pullState.endRefresh() } } } diff --git a/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt b/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt index bc73c65..ad2aff9 100644 --- a/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt +++ b/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt @@ -34,9 +34,7 @@ class RocketsViewModelTest { private lateinit var getRocketsUseCase: GetRocketsUseCase // there is some issue with mocking functional interface with kotlin.Result(Unit) - private val refreshRocketsUseCase: RefreshRocketsUseCase = RefreshRocketsUseCase { - Result.failure(IllegalStateException("Test error")) - } + private lateinit var refreshRocketsUseCase: RefreshRocketsUseCase @SpyK private var savedStateHandle = SavedStateHandle() @@ -46,6 +44,9 @@ class RocketsViewModelTest { @BeforeEach fun setUp() { MockKAnnotations.init(this) + refreshRocketsUseCase = RefreshRocketsUseCase { + Result.success(Unit) + } } @Test @@ -66,6 +67,25 @@ class RocketsViewModelTest { } } + @Test + fun `should show loading state with no error state first during rockets refresh`() = runTest { + // Given + every { getRocketsUseCase() } returns emptyFlow() + setUpRocketsViewModel() + + // When + objectUnderTest.acceptIntent(RefreshRockets) + + // Then + objectUnderTest.uiState.test { + val actualItem = awaitItem() + println(actualItem) + + assertTrue(actualItem.isLoading) + assertFalse(actualItem.isError) + } + } + @Test fun `should show fetched rockets with no loading & error state during init rockets retrieval success`() = runTest { // Given @@ -120,6 +140,9 @@ class RocketsViewModelTest { every { getRocketsUseCase() } returns flowOf( Result.success(testRocketsFromDomain), ) + refreshRocketsUseCase = RefreshRocketsUseCase { + Result.failure(IllegalStateException("Test error")) + } setUpRocketsViewModel() // When diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71d4e7e..80e71fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ accompanist = "0.32.0" coil = "2.5.0" compose-bom = "2023.10.01" compose-compiler = "1.5.8" +compose-material3 = "1.2.0-beta02" desugar = "2.0.4" hilt = "2.50" kotlin = "1.9.22" @@ -53,7 +54,7 @@ ktlint = { id = "org.jmailen.kotlinter", version.re accompanist-swipe-refresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } -compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } detekt-compose-rules = { module = "io.nlopez.compose.rules:detekt", version.ref = "detekt-compose-rules" } hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }