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:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 45
timeout-minutes: 55
strategy:
matrix:
api-level: [23, 26, 30]

@ -88,7 +88,8 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
implementation(project(":sync"))
implementation(project(":sync:work"))
implementation(project(":sync:sync-test"))
androidTestImplementation(project(":core:testing"))
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.FakeTopicsRepository
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.Module
import dagger.hilt.components.SingletonComponent
@ -55,4 +56,9 @@ interface TestDataModule {
fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository
): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor
): NetworkMonitor
}

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

@ -16,5 +16,5 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.data">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</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.TopicsRepository
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.Module
import dagger.hilt.InstallIn
@ -52,4 +54,9 @@ interface DataModule {
fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository
): 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_YES, name = "Dark theme")
private annotation class ThemePreviews
annotation class ThemePreviews
@ThemePreviews
@Composable

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable
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.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlinx.coroutines.launch
@ -125,14 +124,25 @@ fun NiaLoadingWheel(
}
}
@Preview(
name = "Loading Wheel Light Preview",
uiMode = Configuration.UI_MODE_NIGHT_NO,
)
@Preview(
name = "Loading Wheel Dark Preview",
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Composable
fun NiaOverlayLoadingWheel(
contentDesc: String,
modifier: Modifier = Modifier
) {
Surface(
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
fun NiaLoadingWheelPreview() {
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 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.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.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.Preview
import androidx.compose.ui.unit.dp
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.model.data.SaveableNewsResource
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.
* 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(
feedState: NewsFeedUiState,
showLoadingUIIfLoading: Boolean,
@StringRes loadingContentDescription: Int,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit
) {
when (feedState) {
NewsFeedUiState.Loading -> {
if (showLoadingUIIfLoading) {
item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
contentDesc = stringResource(loadingContentDescription),
)
}
}
}
NewsFeedUiState.Loading -> Unit
is NewsFeedUiState.Success -> {
items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource ->
val resourceUrl by remember {
@ -121,8 +97,6 @@ fun NewsFeedLoadingPreview() {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
feedState = NewsFeedUiState.Loading,
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
@ -141,8 +115,6 @@ fun NewsFeedContentPreview() {
SaveableNewsResource(it, false)
}
),
showLoadingUIIfLoading = true,
loadingContentDescription = 0,
onNewsResourcesCheckedChanged = { _, _ -> }
)
}

@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
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.GridItemSpan
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.lifecycle.compose.ExperimentalLifecycleComposeApi
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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -95,11 +98,21 @@ fun BookmarksScreen(
.padding(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(
feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
showLoadingUIIfLoading = true,
loadingContentDescription = R.string.saved_loading
)
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.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -54,6 +53,8 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
@ -71,11 +72,37 @@ class ForYouScreenTest {
.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
fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics,
@ -124,6 +151,8 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
@ -175,6 +204,8 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
@ -226,6 +257,8 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
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
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
@ -262,6 +285,8 @@ class ForYouScreenTest {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading,
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
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
@ -293,6 +308,8 @@ class ForYouScreenTest {
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
composeTestRule.setContent {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {

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

@ -17,7 +17,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
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.MaterialTheme
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.Text
import androidx.compose.material3.TopAppBarDefaults
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.graphics.Color
@ -77,7 +82,7 @@ import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
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.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -101,7 +106,12 @@ fun ForYouRoute(
) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isOffline by viewModel.isOffline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
ForYouScreen(
isOffline = isOffline,
isSyncing = isSyncing,
interestsSelectionState = interestsSelectionState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
@ -115,6 +125,8 @@ fun ForYouRoute(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ForYouScreen(
isOffline: Boolean,
isSyncing: Boolean,
interestsSelectionState: ForYouInterestsSelectionUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
@ -123,7 +135,10 @@ fun ForYouScreen(
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
NiaTopAppBar(
titleRes = R.string.top_app_bar_title,
@ -165,6 +180,14 @@ fun ForYouScreen(
val lazyGridState = rememberLazyGridState()
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(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
@ -198,21 +221,36 @@ fun ForYouScreen(
newsFeed(
feedState = feedState,
// Avoid showing a second loading wheel if we already are for the interests
// selection
showLoadingUIIfLoading =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
loadingContentDescription = R.string.for_you_loading
)
item(span = { GridItemSpan(maxLineSpan) }) {
Column {
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))
}
}
}
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.
* 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(
interestsSelectionState: ForYouInterestsSelectionUiState,
@ -232,17 +267,8 @@ private fun LazyGridScope.interestsSelection(
interestsItemModifier: Modifier = Modifier
) {
when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading -> {
item(span = { GridItemSpan(maxLineSpan) }) {
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
}
ForYouInterestsSelectionUiState.Loading,
ForYouInterestsSelectionUiState.LoadFailed,
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
item(span = { GridItemSpan(maxLineSpan) }) {
@ -415,6 +441,31 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints {
NiaTheme {
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,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
@ -436,6 +487,8 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
@ -460,6 +513,8 @@ fun ForYouScreenLoading() {
BoxWithConstraints {
NiaTheme {
ForYouScreen(
isOffline = false,
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
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.TopicsRepository
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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -53,6 +55,8 @@ import kotlinx.coroutines.launch
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class ForYouViewModel @Inject constructor(
networkMonitor: NetworkMonitor,
syncStatusMonitor: SyncStatusMonitor,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository,
@ -105,6 +109,21 @@ class ForYouViewModel @Inject constructor(
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> =
combine(
followedInterestsUiState,
@ -174,7 +193,7 @@ class ForYouViewModel @Inject constructor(
}
if (topics.isEmpty() && authors.isEmpty()) {
ForYouInterestsSelectionUiState.Loading
ForYouInterestsSelectionUiState.LoadFailed
} else {
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = topics,

@ -24,6 +24,7 @@
<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_search">Search</string>
<string name="for_you_not_connected">⚠️ You arent connected to the internet</string>
<!-- Authors-->
<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.TestUserDataRepository
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 kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ -49,6 +51,8 @@ class ForYouViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
@ -58,6 +62,8 @@ class ForYouViewModelTest {
@Before
fun setup() {
viewModel = ForYouViewModel(
networkMonitor = networkMonitor,
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
topicsRepository = topicsRepository,
@ -93,6 +99,21 @@ class ForYouViewModelTest {
collectJob2.cancel()
}
@Test
fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest {
syncStatusMonitor.setSyncing(true)
val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
assertEquals(
true,
viewModel.isSyncing.value
)
collectJob.cancel()
}
@Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 =
@ -1369,6 +1390,21 @@ class ForYouViewModelTest {
collectJob1.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(

@ -14,7 +14,8 @@ androidxCustomView = "1.0.0"
androidxDataStore = "1.0.0"
androidxEspresso = "3.4.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"
androidxNavigation = "2.5.2"
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-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-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-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" }

@ -61,7 +61,8 @@ include(":feature:interests")
include(":feature:bookmarks")
include(":feature:topic")
include(":lint")
include(":sync")
include(":sync:work")
include(":sync:sync-test")
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.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.startup)
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
private const val SyncWorkName = "SyncWorkName"
internal const val SyncWorkName = "SyncWorkName"
/**
* 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