From c1316a2f232bfa406eccb5464ddfdd962ec3894f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 26 Sep 2022 01:29:17 -0400 Subject: [PATCH] Give feedback when syncing on ForYouScreen Change-Id: I10a646e8a17f81d96351e69d36f0cb6ccf28e05c --- .github/workflows/Build.yaml | 2 +- app/build.gradle.kts | 3 +- .../data/test/AlwaysOnlineNetworkMonitor.kt | 26 ++++ .../core/data/test/TestDataModule.kt | 6 + core/data/build.gradle.kts | 2 + core/data/src/main/AndroidManifest.xml | 2 +- .../nowinandroid/core/data/di/DataModule.kt | 7 + .../util/ConnectivityManagerNetworkMonitor.kt | 78 ++++++++++++ .../core/data/util/NetworkMonitor.kt | 26 ++++ .../core/data/util/SyncStatusMonitor.kt | 26 ++++ .../core/designsystem/component/Background.kt | 2 +- .../designsystem/component/LoadingWheel.kt | 40 ++++-- .../core/testing/util/TestNetworkMonitor.kt | 35 +++++ .../testing/util/TestSyncStatusMonitor.kt | 35 +++++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 30 +---- .../feature/bookmarks/BookmarksScreen.kt | 17 ++- .../feature/foryou/ForYouScreenTest.kt | 59 ++++++--- .../foryou/ForYouInterestsSelectionUiState.kt | 5 + .../feature/foryou/ForYouScreen.kt | 120 +++++++++++++++--- .../feature/foryou/ForYouViewModel.kt | 21 ++- .../foryou/src/main/res/values/strings.xml | 1 + .../feature/foryou/ForYouViewModelTest.kt | 36 ++++++ gradle/libs.versions.toml | 4 +- settings.gradle.kts | 3 +- sync/{ => sync-test}/.gitignore | 0 sync/sync-test/build.gradle.kts | 25 ++++ sync/sync-test/src/main/AndroidManifest.xml | 20 +++ .../test/NeverSyncingSyncStatusMonitor.kt | 26 ++++ .../core/sync/test/TestSyncModule.kt | 36 ++++++ sync/work/.gitignore | 1 + sync/{ => work}/build.gradle.kts | 1 + .../sync/workers/SyncWorkerTest.kt | 0 sync/{ => work}/src/main/AndroidManifest.xml | 0 .../apps/nowinandroid/sync/di/SyncModule.kt | 33 +++++ .../sync/initializers/SyncInitializer.kt | 2 +- .../sync/initializers/SyncWorkHelpers.kt | 0 .../status/WorkManagerSyncStatusMonitor.kt | 47 +++++++ .../sync/workers/DelegatingWorker.kt | 0 .../nowinandroid/sync/workers/SyncWorker.kt | 0 .../src/main/res/values/strings.xml | 0 40 files changed, 686 insertions(+), 91 deletions(-) create mode 100644 core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/AlwaysOnlineNetworkMonitor.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/NetworkMonitor.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestNetworkMonitor.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt rename sync/{ => sync-test}/.gitignore (100%) create mode 100644 sync/sync-test/build.gradle.kts create mode 100644 sync/sync-test/src/main/AndroidManifest.xml create mode 100644 sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt create mode 100644 sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt create mode 100644 sync/work/.gitignore rename sync/{ => work}/build.gradle.kts (96%) rename sync/{ => work}/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt (100%) rename sync/{ => work}/src/main/AndroidManifest.xml (100%) create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt rename sync/{ => work}/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt (97%) rename sync/{ => work}/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt (100%) create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt rename sync/{ => work}/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt (100%) rename sync/{ => work}/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt (100%) rename sync/{ => work}/src/main/res/values/strings.xml (100%) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index e19caebef..df7618199 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -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] diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f25807ba..f887e7f35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/AlwaysOnlineNetworkMonitor.kt b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/AlwaysOnlineNetworkMonitor.kt new file mode 100644 index 000000000..91e47b688 --- /dev/null +++ b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/AlwaysOnlineNetworkMonitor.kt @@ -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 = flowOf(true) +} diff --git a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index fa31a37a5..9b95aaed0 100644 --- a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -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 } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a848afbe3..750be3700 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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) diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml index 431264e4a..ab11f61c4 100644 --- a/core/data/src/main/AndroidManifest.xml +++ b/core/data/src/main/AndroidManifest.xml @@ -16,5 +16,5 @@ --> - + \ No newline at end of file diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index 3e30e7e59..8f66787f3 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -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 } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt new file mode 100644 index 000000000..9a085af05 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt @@ -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 = callbackFlow { + 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?.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 + } + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/NetworkMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/NetworkMonitor.kt new file mode 100644 index 000000000..1efc03c14 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/NetworkMonitor.kt @@ -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 +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt new file mode 100644 index 000000000..14823ed0e --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt @@ -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 +} diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt index 6525acc0a..985471534 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt @@ -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 diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt index 4ce7e1473..ba3f7c2b0 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt @@ -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 diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestNetworkMonitor.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestNetworkMonitor.kt new file mode 100644 index 000000000..ef4b06b8a --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestNetworkMonitor.kt @@ -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 = connectivityFlow + + /** + * A test-only API to set the connectivity state from tests. + */ + fun setConnected(isConnected: Boolean) { + connectivityFlow.value = isConnected + } +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt new file mode 100644 index 000000000..a2edc89ff --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt @@ -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 = syncStatusFlow + + /** + * A test-only API to set the sync status from tests. + */ + fun setSyncing(isSyncing: Boolean) { + syncStatusFlow.value = isSyncing + } +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 400cc1438..67a57edcd 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -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 = { _, _ -> } ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index f23e9115e..cdd1e2575 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -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) }) { diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index 206c709d2..231d1b861 100644 --- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -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 { diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt index bce65611c..5bde56ed9 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouInterestsSelectionUiState.kt @@ -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. */ diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index db0b53cf9..1fce535fd 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -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 = { _, _ -> } + ) + } + } +} diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index a64e6b1c4..c532319d3 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -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>(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 = combine( followedInterestsUiState, @@ -174,7 +193,7 @@ class ForYouViewModel @Inject constructor( } if (topics.isEmpty() && authors.isEmpty()) { - ForYouInterestsSelectionUiState.Loading + ForYouInterestsSelectionUiState.LoadFailed } else { ForYouInterestsSelectionUiState.WithInterestsSelection( topics = topics, diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/src/main/res/values/strings.xml index dfc451e53..e8f432bf7 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Now in Android My account Search + ⚠️ You aren’t connected to the internet You are following diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index 558b18056..7a939638f 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -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( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b19b1b866..692a84c66 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 850ea78ca..5c30040cf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/sync/.gitignore b/sync/sync-test/.gitignore similarity index 100% rename from sync/.gitignore rename to sync/sync-test/.gitignore diff --git a/sync/sync-test/build.gradle.kts b/sync/sync-test/build.gradle.kts new file mode 100644 index 000000000..4abdf001c --- /dev/null +++ b/sync/sync-test/build.gradle.kts @@ -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")) +} diff --git a/sync/sync-test/src/main/AndroidManifest.xml b/sync/sync-test/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7550c99bb --- /dev/null +++ b/sync/sync-test/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt new file mode 100644 index 000000000..0c0069f7d --- /dev/null +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt @@ -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 = flowOf(false) +} diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt new file mode 100644 index 000000000..bc0876a7c --- /dev/null +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt @@ -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 +} diff --git a/sync/work/.gitignore b/sync/work/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/sync/work/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sync/build.gradle.kts b/sync/work/build.gradle.kts similarity index 96% rename from sync/build.gradle.kts rename to sync/work/build.gradle.kts index 68ce5d357..2bf94d6c2 100644 --- a/sync/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -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) diff --git a/sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt b/sync/work/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt similarity index 100% rename from sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt rename to sync/work/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt diff --git a/sync/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml similarity index 100% rename from sync/src/main/AndroidManifest.xml rename to sync/work/src/main/AndroidManifest.xml diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt new file mode 100644 index 000000000..88e7df4de --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -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 +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt similarity index 97% rename from sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index aa9dbffd5..21f98138b 100644 --- a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -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. diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt similarity index 100% rename from sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt new file mode 100644 index 000000000..fe9c429e0 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt @@ -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 = + Transformations.map( + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName), + MutableList::anyRunning + ) + .asFlow() + .conflate() +} + +private val List.anyRunning get() = any { it.state == State.RUNNING } diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt similarity index 100% rename from sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt similarity index 100% rename from sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt diff --git a/sync/src/main/res/values/strings.xml b/sync/work/src/main/res/values/strings.xml similarity index 100% rename from sync/src/main/res/values/strings.xml rename to sync/work/src/main/res/values/strings.xml