Give feedback when syncing on ForYouScreen

Change-Id: I10a646e8a17f81d96351e69d36f0cb6ccf28e05c
pull/304/head
Adetunji Dahunsi 2 years ago
parent 14ae167b69
commit c4debb74e7

@ -61,7 +61,7 @@ jobs:
androidTest: androidTest:
needs: build needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 45 timeout-minutes: 55
strategy: strategy:
matrix: matrix:
api-level: [23, 26, 30] api-level: [23, 26, 30]

@ -88,7 +88,8 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:navigation")) implementation(project(":core:navigation"))
implementation(project(":sync")) implementation(project(":sync:work"))
implementation(project(":sync:sync-test"))
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
androidTestImplementation(project(":core:datastore-test")) androidTestImplementation(project(":core:datastore-test"))

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true)
}

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeAuthor
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@ -55,4 +56,9 @@ interface TestDataModule {
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository userDataRepository: FakeUserDataRepository
): UserDataRepository ): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor
): NetworkMonitor
} }

@ -30,6 +30,8 @@ dependencies {
testImplementation(project(":core:testing")) testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test")) testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)

@ -16,5 +16,5 @@
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.data"> package="com.google.samples.apps.nowinandroid.core.data">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest> </manifest>

@ -24,6 +24,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTop
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -52,4 +54,9 @@ interface DataModule {
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository userDataRepository: OfflineFirstUserDataRepository
): UserDataRepository ): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor
): NetworkMonitor
} }

@ -0,0 +1,78 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.data.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest.Builder
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow<Boolean> {
val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
channel.trySend(true)
}
override fun onLost(network: Network) {
channel.trySend(false)
}
}
val connectivityManager = context.getSystemService<ConnectivityManager>()
connectivityManager?.registerNetworkCallback(
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback
)
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager?.unregisterNetworkCallback(callback)
}
}
.conflate()
@Suppress("DEPRECATION")
private fun ConnectivityManager?.isCurrentlyConnected() = when (this) {
null -> false
else -> when {
VERSION.SDK_INT >= VERSION_CODES.M ->
activeNetwork
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
else -> activeNetworkInfo?.isConnected ?: false
}
}
}

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.data.util
import kotlinx.coroutines.flow.Flow
/**
* Utility for reporting app connectivity status
*/
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.data.util
import kotlinx.coroutines.flow.Flow
/**
* Reports on if synchronization is in progress
*/
interface SyncStatusMonitor {
val isSyncing: Flow<Boolean>
}

@ -139,7 +139,7 @@ fun NiaGradientBackground(
*/ */
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme")
private annotation class ThemePreviews annotation class ThemePreviews
@ThemePreviews @ThemePreviews
@Composable @Composable

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.animation.animateColor import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
@ -31,6 +30,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,7 +44,6 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -125,14 +124,25 @@ fun NiaLoadingWheel(
} }
} }
@Preview( @Composable
name = "Loading Wheel Light Preview", fun NiaOverlayLoadingWheel(
uiMode = Configuration.UI_MODE_NIGHT_NO, contentDesc: String,
) modifier: Modifier = Modifier
@Preview( ) {
name = "Loading Wheel Dark Preview", Surface(
uiMode = Configuration.UI_MODE_NIGHT_YES, shape = RoundedCornerShape(60.dp),
) shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.83f),
modifier = modifier
.size(60.dp),
) {
NiaLoadingWheel(
contentDesc = contentDesc,
)
}
}
@ThemePreviews
@Composable @Composable
fun NiaLoadingWheelPreview() { fun NiaLoadingWheelPreview() {
NiaTheme { NiaTheme {
@ -142,5 +152,15 @@ fun NiaLoadingWheelPreview() {
} }
} }
@ThemePreviews
@Composable
fun NiaOverlayLoadingWheelPreview() {
NiaTheme {
Surface {
NiaOverlayLoadingWheel(contentDesc = "LoadingWheel")
}
}
}
private const val ROTATION_TIME = 12000 private const val ROTATION_TIME = 12000
private const val NUM_OF_LINES = 12 private const val NUM_OF_LINES = 12

@ -0,0 +1,35 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.testing.util
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class TestNetworkMonitor : NetworkMonitor {
private val connectivityFlow = MutableStateFlow(true)
override val isOnline: Flow<Boolean> = connectivityFlow
/**
* A test-only API to set the connectivity state from tests.
*/
fun setConnected(isConnected: Boolean) {
connectivityFlow.value = isConnected
}
}

@ -0,0 +1,35 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.testing.util
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class TestSyncStatusMonitor : SyncStatusMonitor {
private val syncStatusFlow = MutableStateFlow(false)
override val isSyncing: Flow<Boolean> = syncStatusFlow
/**
* A test-only API to set the sync status from tests.
*/
fun setSyncing(isSyncing: Boolean) {
syncStatusFlow.value = isSyncing
}
}

@ -18,12 +18,8 @@ package com.google.samples.apps.nowinandroid.core.ui
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
@ -31,14 +27,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
@ -46,30 +39,13 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
/** /**
* An extension on [LazyListScope] defining a feed with news resources. * An extension on [LazyListScope] defining a feed with news resources.
* Depending on the [feedState], this might emit no items. * Depending on the [feedState], this might emit no items.
*
* @param showLoadingUIIfLoading if true, show a visual indication of loading if the
* [feedState] is loading. This allows a caller to suppress a loading visual if one is already
* present in the UI elsewhere.
*/ */
fun LazyGridScope.newsFeed( fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
showLoadingUIIfLoading: Boolean,
@StringRes loadingContentDescription: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) { ) {
when (feedState) { when (feedState) {
NewsFeedUiState.Loading -> { NewsFeedUiState.Loading -> Unit
if (showLoadingUIIfLoading) {
item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(loadingContentDescription),
)
}
}
}
is NewsFeedUiState.Success -> { is NewsFeedUiState.Success -> {
items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource -> items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource ->
val resourceUrl by remember { val resourceUrl by remember {
@ -121,8 +97,6 @@ fun NewsFeedLoadingPreview() {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) { LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed( newsFeed(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }
@ -141,8 +115,6 @@ fun NewsFeedContentPreview() {
SaveableNewsResource(it, false) SaveableNewsResource(it, false)
} }
), ),
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }

@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumedWindowInsets import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -42,6 +44,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -95,11 +98,21 @@ fun BookmarksScreen(
.padding(innerPadding) .padding(innerPadding)
.consumedWindowInsets(innerPadding) .consumedWindowInsets(innerPadding)
) { ) {
if (feedState is NewsFeedUiState.Loading) {
item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.saved_loading),
)
}
}
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
showLoadingUIIfLoading = true,
loadingContentDescription = R.string.saved_loading
) )
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {

@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -54,6 +53,8 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -71,11 +72,37 @@ class ForYouScreenTest {
.assertExists() .assertExists()
} }
@Test
fun circularProgressIndicator_whenScreenIsSyncing_exists() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test @Test
fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
@ -124,6 +151,8 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -175,6 +204,8 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic // Follow one topic
@ -226,6 +257,8 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics, topics = testTopics,
@ -240,16 +273,6 @@ class ForYouScreenTest {
} }
} }
// Scroll until the loading indicator is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(
hasContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
)
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading) composeTestRule.activity.resources.getString(R.string.for_you_loading)
@ -262,6 +285,8 @@ class ForYouScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -272,16 +297,6 @@ class ForYouScreenTest {
} }
} }
// Scroll until the loading indicator is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(
hasContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
)
composeTestRule composeTestRule
.onNodeWithContentDescription( .onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading) composeTestRule.activity.resources.getString(R.string.for_you_loading)
@ -293,6 +308,8 @@ class ForYouScreenTest {
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() { fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
composeTestRule.setContent { composeTestRule.setContent {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {

@ -28,6 +28,11 @@ sealed interface ForYouInterestsSelectionUiState {
*/ */
object Loading : ForYouInterestsSelectionUiState object Loading : ForYouInterestsSelectionUiState
/**
* The interests selection state was unable to load.
*/
object LoadFailed : ForYouInterestsSelectionUiState
/** /**
* There is no interests selection state. * There is no interests selection state.
*/ */

@ -17,7 +17,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity import android.app.Activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -35,7 +37,6 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
@ -51,12 +52,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -77,7 +82,7 @@ import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -101,7 +106,12 @@ fun ForYouRoute(
) { ) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle() val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isOffline by viewModel.isOffline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
isOffline = isOffline,
isSyncing = isSyncing,
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
feedState = feedState, feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
@ -115,6 +125,8 @@ fun ForYouRoute(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ForYouScreen( fun ForYouScreen(
isOffline: Boolean,
isSyncing: Boolean,
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -123,7 +135,10 @@ fun ForYouScreen(
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
NiaTopAppBar( NiaTopAppBar(
titleRes = R.string.top_app_bar_title, titleRes = R.string.top_app_bar_title,
@ -165,6 +180,14 @@ fun ForYouScreen(
val lazyGridState = rememberLazyGridState() val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyGridState, stateName = tag) TrackScrollJank(scrollableState = lazyGridState, stateName = tag)
val notConnected = stringResource(R.string.for_you_not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnected,
duration = Indefinite
)
}
LazyVerticalGrid( LazyVerticalGrid(
columns = Adaptive(300.dp), columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
@ -198,21 +221,36 @@ fun ForYouScreen(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests
// selection
showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
loadingContentDescription = R.string.for_you_loading
) )
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Column { Column {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar.
if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
} }
} }
AnimatedVisibility(
visible = isSyncing ||
feedState is NewsFeedUiState.Loading ||
interestsSelectionState is ForYouInterestsSelectionUiState.Loading
) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier
.padding(innerPadding)
.consumedWindowInsets(innerPadding)
.fillMaxWidth()
) {
NiaOverlayLoadingWheel(
modifier = Modifier.align(Alignment.Center),
contentDesc = loadingContentDescription
)
}
}
} }
} }
@ -220,9 +258,6 @@ fun ForYouScreen(
* An extension on [LazyListScope] defining the interests selection portion of the for you screen. * An extension on [LazyListScope] defining the interests selection portion of the for you screen.
* Depending on the [interestsSelectionState], this might emit no items. * Depending on the [interestsSelectionState], this might emit no items.
* *
* @param showLoaderWhenLoading if true, show a visual indication of loading if the
* [interestsSelectionState] is loading. This is controllable to permit du-duplicating loading
* states.
*/ */
private fun LazyGridScope.interestsSelection( private fun LazyGridScope.interestsSelection(
interestsSelectionState: ForYouInterestsSelectionUiState, interestsSelectionState: ForYouInterestsSelectionUiState,
@ -232,17 +267,8 @@ private fun LazyGridScope.interestsSelection(
interestsItemModifier: Modifier = Modifier interestsItemModifier: Modifier = Modifier
) { ) {
when (interestsSelectionState) { when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading -> { ForYouInterestsSelectionUiState.Loading,
item(span = { GridItemSpan(maxLineSpan) }) { ForYouInterestsSelectionUiState.LoadFailed,
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> { is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@ -415,6 +441,31 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
@DevicePreviews
@Composable
fun ForYouScreenOfflinePopulatedFeed() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
isOffline = true,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
@ -436,6 +487,8 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) } authors = previewAuthors.map { FollowableAuthor(it, false) }
@ -460,6 +513,8 @@ fun ForYouScreenLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -470,3 +525,26 @@ fun ForYouScreenLoading() {
} }
} }
} }
@DevicePreviews
@Composable
fun ForYouScreenPopulatedAndLoading() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
isOffline = false,
isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}

@ -28,6 +28,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsReposito
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -53,6 +55,8 @@ import kotlinx.coroutines.launch
@OptIn(SavedStateHandleSaveableApi::class) @OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
networkMonitor: NetworkMonitor,
syncStatusMonitor: SyncStatusMonitor,
authorsRepository: AuthorsRepository, authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository, topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository, private val newsRepository: NewsRepository,
@ -105,6 +109,21 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
val isSyncing = syncStatusMonitor.isSyncing
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
combine( combine(
followedInterestsUiState, followedInterestsUiState,
@ -174,7 +193,7 @@ class ForYouViewModel @Inject constructor(
} }
if (topics.isEmpty() && authors.isEmpty()) { if (topics.isEmpty() && authors.isEmpty()) {
ForYouInterestsSelectionUiState.Loading ForYouInterestsSelectionUiState.LoadFailed
} else { } else {
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = topics, topics = topics,

@ -24,6 +24,7 @@
<string name="top_app_bar_title">Now in Android</string> <string name="top_app_bar_title">Now in Android</string>
<string name="for_you_top_app_bar_action_my_account">My account</string> <string name="for_you_top_app_bar_action_my_account">My account</string>
<string name="for_you_top_app_bar_action_search">Search</string> <string name="for_you_top_app_bar_action_search">Search</string>
<string name="for_you_not_connected">⚠️ You arent connected to the internet</string>
<!-- Authors--> <!-- Authors-->
<string name="following">You are following</string> <string name="following">You are following</string>

@ -29,6 +29,8 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,6 +51,8 @@ class ForYouViewModelTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
@ -58,6 +62,8 @@ class ForYouViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
networkMonitor = networkMonitor,
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
authorsRepository = authorsRepository, authorsRepository = authorsRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
@ -93,6 +99,21 @@ class ForYouViewModelTest {
collectJob2.cancel() collectJob2.cancel()
} }
@Test
fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest {
syncStatusMonitor.setSyncing(true)
val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
assertEquals(
true,
viewModel.isSyncing.value
)
collectJob.cancel()
}
@Test @Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest { fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
@ -1369,6 +1390,21 @@ class ForYouViewModelTest {
collectJob1.cancel() collectJob1.cancel()
collectJob2.cancel() collectJob2.cancel()
} }
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest {
val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.isOffline.collect() }
networkMonitor.setConnected(false)
assertEquals(
true,
viewModel.isOffline.value
)
collectJob.cancel()
}
} }
private val sampleAuthors = listOf( private val sampleAuthors = listOf(

@ -14,7 +14,8 @@ androidxCustomView = "1.0.0"
androidxDataStore = "1.0.0" androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0" androidxEspresso = "3.4.0"
androidxHiltNavigationCompose = "1.0.0" androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.0-alpha02" # Skipping version 2.6.0-alpha02 due to https://issuetracker.google.com/249686765
androidxLifecycle = "2.6.0-alpha01"
androidxMacroBenchmark = "1.1.0" androidxMacroBenchmark = "1.1.0"
androidxNavigation = "2.5.2" androidxNavigation = "2.5.2"
androidxMetrics = "1.0.0-alpha03" androidxMetrics = "1.0.0-alpha03"
@ -74,6 +75,7 @@ androidx-customview-poolingcontainer = { group = "androidx.customview", name = "
androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" } androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" }

@ -61,7 +61,8 @@ include(":feature:interests")
include(":feature:bookmarks") include(":feature:bookmarks")
include(":feature:topic") include(":feature:topic")
include(":lint") include(":lint")
include(":sync") include(":sync:work")
include(":sync:sync-test")
val prePushHook = file(".git/hooks/pre-push") val prePushHook = file(".git/hooks/pre-push")

@ -0,0 +1,25 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.hilt")
}
dependencies {
api(project(":sync:work"))
implementation(project(":core:data"))
implementation(project(":core:testing"))
}

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
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
http://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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.sync.test">
</manifest>

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.sync.test
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class NeverSyncingSyncStatusMonitor @Inject constructor() : SyncStatusMonitor {
override val isSyncing: Flow<Boolean> = flowOf(false)
}

@ -0,0 +1,36 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.core.sync.test
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.sync.di.SyncModule
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [SyncModule::class]
)
interface TestSyncModule {
@Binds
fun bindsSyncStatusMonitor(
syncStatusMonitor: NeverSyncingSyncStatusMonitor
): SyncStatusMonitor
}

@ -0,0 +1 @@
/build

@ -33,6 +33,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.androidx.work.ktx) implementation(libs.androidx.work.ktx)

@ -0,0 +1,33 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.sync.di
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncStatusMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface SyncModule {
@Binds
fun bindsSyncStatusMonitor(
syncStatusMonitor: WorkManagerSyncStatusMonitor
): SyncStatusMonitor
}

@ -35,7 +35,7 @@ object Sync {
} }
// This name should not be changed otherwise the app may have concurrent sync requests running // This name should not be changed otherwise the app may have concurrent sync requests running
private const val SyncWorkName = "SyncWorkName" internal const val SyncWorkName = "SyncWorkName"
/** /**
* Registers work to sync the data layer periodically on app startup. * Registers work to sync the data layer periodically on app startup.

@ -0,0 +1,47 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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 com.google.samples.apps.nowinandroid.sync.status
import android.content.Context
import androidx.lifecycle.Transformations
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkInfo.State
import androidx.work.WorkManager
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
/**
* [SyncStatusMonitor] backed by [WorkInfo] from [WorkManager]
*/
class WorkManagerSyncStatusMonitor @Inject constructor(
@ApplicationContext context: Context
) : SyncStatusMonitor {
override val isSyncing: Flow<Boolean> =
Transformations.map(
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName),
MutableList<WorkInfo>::anyRunning
)
.asFlow()
.conflate()
}
private val List<WorkInfo>.anyRunning get() = any { it.state == State.RUNNING }
Loading…
Cancel
Save