commit
1b41864a2c
@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.foryou
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertHasClickAction
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import com.google.samples.apps.nowinandroid.R
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ForYouScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun circularProgressIndicator_whenScreenIsLoading_exists() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.Loading,
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithContentDescription(
|
||||||
|
composeTestRule.activity.resources.getString(R.string.for_you_loading)
|
||||||
|
)
|
||||||
|
.assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("HEADLINES")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("UI")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("TOOLS")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertIsNotEnabled()
|
||||||
|
.assertHasClickAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to true,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("HEADLINES")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("UI")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOn()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("TOOLS")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
// .assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertIsEnabled()
|
||||||
|
.assertHasClickAction()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 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.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.retry
|
||||||
|
|
||||||
|
class NiaPreferences @Inject constructor(
|
||||||
|
private val userPreferences: DataStore<UserPreferences>
|
||||||
|
) {
|
||||||
|
suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) {
|
||||||
|
try {
|
||||||
|
userPreferences.updateData {
|
||||||
|
it.copy {
|
||||||
|
this.followedTopicIds.clear()
|
||||||
|
this.followedTopicIds.addAll(followedTopicIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ioException: IOException) {
|
||||||
|
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val followedTopicIds: Flow<Set<Int>> = userPreferences.data
|
||||||
|
.retry {
|
||||||
|
Log.e("NiaPreferences", "Failed to read user preferences", it)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.map { it.followedTopicIdsList.toSet() }
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.CorruptionException
|
||||||
|
import androidx.datastore.core.Serializer
|
||||||
|
import com.google.protobuf.InvalidProtocolBufferException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [androidx.datastore.core.Serializer] for the [UserPreferences] proto.
|
||||||
|
*/
|
||||||
|
class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {
|
||||||
|
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
|
||||||
|
|
||||||
|
override suspend fun readFrom(input: InputStream): UserPreferences =
|
||||||
|
try {
|
||||||
|
// readFrom is already called on the data store background thread
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
UserPreferences.parseFrom(input)
|
||||||
|
} catch (exception: InvalidProtocolBufferException) {
|
||||||
|
throw CorruptionException("Cannot read proto.", exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
|
||||||
|
// writeTo is already called on the data store background thread
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
t.writeTo(output)
|
||||||
|
}
|
||||||
|
}
|
@ -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.data.news
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Topic(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val description: String
|
||||||
|
)
|
@ -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.data.news
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface TopicsRepository {
|
||||||
|
/**
|
||||||
|
* Gets the available topics as a stream
|
||||||
|
*/
|
||||||
|
fun getTopicsStream(): Flow<List<Topic>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user's currently followed topics
|
||||||
|
*/
|
||||||
|
suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the users currently followed topics
|
||||||
|
*/
|
||||||
|
fun getFollowedTopicIdsStream(): Flow<Set<Int>>
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.news.fake
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.data.NiaPreferences
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.di.NiaDispatchers
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class FakeTopicsRepository @Inject constructor(
|
||||||
|
private val dispatchers: NiaDispatchers,
|
||||||
|
private val networkJson: Json,
|
||||||
|
private val niaPreferences: NiaPreferences
|
||||||
|
) : TopicsRepository {
|
||||||
|
override fun getTopicsStream(): Flow<List<Topic>> = flow<List<Topic>> {
|
||||||
|
emit(networkJson.decodeFromString(FakeDataSource.topicsData))
|
||||||
|
}
|
||||||
|
.flowOn(dispatchers.IO)
|
||||||
|
|
||||||
|
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
|
||||||
|
niaPreferences.setFollowedTopicIds(followedTopicIds)
|
||||||
|
|
||||||
|
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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.di
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
|
|
||||||
|
interface NiaDispatchers {
|
||||||
|
val IO: CoroutineDispatcher
|
||||||
|
|
||||||
|
val Default: CoroutineDispatcher
|
||||||
|
|
||||||
|
val Main: MainCoroutineDispatcher
|
||||||
|
|
||||||
|
val Unconfined: CoroutineDispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultNiaDispatchers @Inject constructor() : NiaDispatchers {
|
||||||
|
override val IO: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
override val Default: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
override val Main: MainCoroutineDispatcher = Dispatchers.Main
|
||||||
|
override val Unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||||
|
}
|
@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui
|
||||||
|
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.util.Size
|
||||||
|
import android.util.SizeF
|
||||||
|
import android.util.SparseArray
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.SnapshotMutationPolicy
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.neverEqualPolicy
|
||||||
|
import androidx.compose.runtime.referentialEqualityPolicy
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.SaverScope
|
||||||
|
import androidx.compose.runtime.saveable.autoSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotMutableState
|
||||||
|
import androidx.compose.runtime.structuralEqualityPolicy
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlin.properties.PropertyDelegateProvider
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
// These are placeholder solutions for https://issuetracker.google.com/issues/195689777
|
||||||
|
// With the following, it is possible to use the Compose Saver APIs to back values in the
|
||||||
|
// SavedStateHandle to persist through process death, in a similar form as rememberSaveable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PropertyDelegateProvider] allowing the use of [saveable] with the key provided by the name
|
||||||
|
* of the property.
|
||||||
|
*/
|
||||||
|
fun <T : Any> SavedStateHandle.saveable(
|
||||||
|
saver: Saver<T, out Any> = autoSaver(),
|
||||||
|
init: () -> T,
|
||||||
|
): PropertyDelegateProvider<Any, ReadOnlyProperty<Any, T>> =
|
||||||
|
PropertyDelegateProvider { _, property ->
|
||||||
|
val value = saveable(
|
||||||
|
key = property.name,
|
||||||
|
saver = saver,
|
||||||
|
init = init
|
||||||
|
)
|
||||||
|
|
||||||
|
ReadOnlyProperty { _, _ -> value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A nested [PropertyDelegateProvider] allowing the use of [saveable] with the key provided by the
|
||||||
|
* name, as well as direct access to the value contained with [MutableState].
|
||||||
|
*
|
||||||
|
* This allows the main usage to look almost identical to
|
||||||
|
* `rememberSaveable { mutableStateOf(...) }`:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val value by savedStateHandle.saveable { mutableStateOf("initialValue") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@JvmName("saveableMutableState")
|
||||||
|
fun <T : Any> SavedStateHandle.saveable(
|
||||||
|
stateSaver: Saver<T, out Any> = autoSaver(),
|
||||||
|
init: () -> MutableState<T>,
|
||||||
|
): PropertyDelegateProvider<Any, ReadWriteProperty<Any, T>> =
|
||||||
|
PropertyDelegateProvider<Any, ReadWriteProperty<Any, T>> { _, property ->
|
||||||
|
val mutableState = saveable(
|
||||||
|
key = property.name,
|
||||||
|
stateSaver = stateSaver,
|
||||||
|
init = init
|
||||||
|
)
|
||||||
|
|
||||||
|
object : ReadWriteProperty<Any, T> {
|
||||||
|
override fun getValue(thisRef: Any, property: KProperty<*>): T =
|
||||||
|
mutableState.getValue(thisRef, property)
|
||||||
|
|
||||||
|
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) =
|
||||||
|
mutableState.setValue(thisRef, property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic interop between [SavedStateHandle] and [Saver], so the latter can be used to save
|
||||||
|
* state holders into the [SavedStateHandle].
|
||||||
|
*
|
||||||
|
* This implementation is based on [rememberSaveable], [SaveableStateRegistry] and
|
||||||
|
* [DisposableSaveableStateRegistry], with some simplifications since there will be exactly one
|
||||||
|
* state provider storing exactly one value.
|
||||||
|
*
|
||||||
|
* This implementation makes use of [SavedStateHandle.setSavedStateProvider], so this
|
||||||
|
* state will not be kept in sync with any other way to change the internal state
|
||||||
|
* of the [SavedStateHandle].
|
||||||
|
*/
|
||||||
|
fun <T : Any> SavedStateHandle.saveable(
|
||||||
|
key: String,
|
||||||
|
saver: Saver<T, out Any> = autoSaver(),
|
||||||
|
init: () -> T,
|
||||||
|
): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
saver as Saver<T, Any>
|
||||||
|
// value is restored using the SavedStateHandle or created via [init] lambda
|
||||||
|
val value = get<Bundle?>(key)?.get("value")?.let(saver::restore) ?: init()
|
||||||
|
|
||||||
|
// Hook up saving the state to the SavedStateHandle
|
||||||
|
setSavedStateProvider(key) {
|
||||||
|
bundleOf("value" to with(saver) { SaverScope(::canBeSavedToBundle).save(value) })
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic interop between [SavedStateHandle] and [Saver], so the latter can be used to save
|
||||||
|
* state holders into the [SavedStateHandle].
|
||||||
|
*
|
||||||
|
* This implementation is based on [rememberSaveable], [SaveableStateRegistry] and
|
||||||
|
* [DisposableSaveableStateRegistry], with some simplifications since there will be exactly one
|
||||||
|
* state provider storing exactly one value.
|
||||||
|
*
|
||||||
|
* This implementation makes use of [SavedStateHandle.setSavedStateProvider], so this
|
||||||
|
* state will not be kept in sync with any other way to change the internal state
|
||||||
|
* of the [SavedStateHandle].
|
||||||
|
*
|
||||||
|
* Use this overload if you remember a mutable state with a type which can't be stored in the
|
||||||
|
* Bundle so you have to provide a custom saver object.
|
||||||
|
*/
|
||||||
|
fun <T> SavedStateHandle.saveable(
|
||||||
|
key: String,
|
||||||
|
stateSaver: Saver<T, out Any>,
|
||||||
|
init: () -> MutableState<T>
|
||||||
|
): MutableState<T> = saveable(
|
||||||
|
saver = mutableStateSaver(stateSaver),
|
||||||
|
key = key,
|
||||||
|
init = init
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copied from RememberSaveable.kt
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> mutableStateSaver(inner: Saver<T, out Any>) = with(inner as Saver<T, Any>) {
|
||||||
|
Saver<MutableState<T>, MutableState<Any?>>(
|
||||||
|
save = { state ->
|
||||||
|
require(state is SnapshotMutableState<T>) {
|
||||||
|
"If you use a custom MutableState implementation you have to write a custom " +
|
||||||
|
"Saver and pass it as a saver param to saveable()"
|
||||||
|
}
|
||||||
|
mutableStateOf(save(state.value), state.policy as SnapshotMutationPolicy<Any?>)
|
||||||
|
},
|
||||||
|
restore = @Suppress("UNCHECKED_CAST") {
|
||||||
|
require(it is SnapshotMutableState<Any?>)
|
||||||
|
mutableStateOf(
|
||||||
|
if (it.value != null) restore(it.value!!) else null,
|
||||||
|
it.policy as SnapshotMutationPolicy<T?>
|
||||||
|
) as MutableState<T>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that [value] can be stored inside [Bundle].
|
||||||
|
*
|
||||||
|
* Copied from DisposableSaveableStateRegistry.android.kt
|
||||||
|
*/
|
||||||
|
private fun canBeSavedToBundle(value: Any): Boolean {
|
||||||
|
// SnapshotMutableStateImpl is Parcelable, but we do extra checks
|
||||||
|
if (value is SnapshotMutableState<*>) {
|
||||||
|
if (value.policy === neverEqualPolicy<Any?>() ||
|
||||||
|
value.policy === structuralEqualityPolicy<Any?>() ||
|
||||||
|
value.policy === referentialEqualityPolicy<Any?>()
|
||||||
|
) {
|
||||||
|
val stateValue = value.value
|
||||||
|
return if (stateValue == null) true else canBeSavedToBundle(stateValue)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (cl in AcceptableClasses) {
|
||||||
|
if (cl.isInstance(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains Classes which can be stored inside [Bundle].
|
||||||
|
*
|
||||||
|
* Some of the classes are not added separately because:
|
||||||
|
*
|
||||||
|
* This classes implement Serializable:
|
||||||
|
* - Arrays (DoubleArray, BooleanArray, IntArray, LongArray, ByteArray, FloatArray, ShortArray,
|
||||||
|
* CharArray, Array<Parcelable, Array<String>)
|
||||||
|
* - ArrayList
|
||||||
|
* - Primitives (Boolean, Int, Long, Double, Float, Byte, Short, Char) will be boxed when casted
|
||||||
|
* to Any, and all the boxed classes implements Serializable.
|
||||||
|
* This class implements Parcelable:
|
||||||
|
* - Bundle
|
||||||
|
*
|
||||||
|
* Note: it is simplified copy of the array from SavedStateHandle (lifecycle-viewmodel-savedstate).
|
||||||
|
*
|
||||||
|
* Copied from DisposableSaveableStateRegistry.android.kt
|
||||||
|
*/
|
||||||
|
private val AcceptableClasses = arrayOf(
|
||||||
|
Serializable::class.java,
|
||||||
|
Parcelable::class.java,
|
||||||
|
String::class.java,
|
||||||
|
SparseArray::class.java,
|
||||||
|
Binder::class.java,
|
||||||
|
Size::class.java,
|
||||||
|
SizeF::class.java
|
||||||
|
)
|
@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.foryou
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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 androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
|
import com.google.samples.apps.nowinandroid.R
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.NewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ForYouRoute(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ForYouViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
ForYouScreen(
|
||||||
|
modifier = modifier,
|
||||||
|
uiState = uiState,
|
||||||
|
onTopicCheckedChanged = viewModel::updateTopicSelection,
|
||||||
|
saveFollowedTopics = viewModel::saveFollowedTopics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ForYouScreen(
|
||||||
|
uiState: ForYouFeedUiState,
|
||||||
|
onTopicCheckedChanged: (Int, Boolean) -> Unit,
|
||||||
|
saveFollowedTopics: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
when (uiState) {
|
||||||
|
ForYouFeedUiState.Loading -> {
|
||||||
|
val forYouLoading = stringResource(id = R.string.for_you_loading)
|
||||||
|
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = forYouLoading
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ForYouFeedUiState.PopulatedFeed -> {
|
||||||
|
LazyColumn {
|
||||||
|
when (uiState) {
|
||||||
|
is ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection -> {
|
||||||
|
TopicSelection(uiState, onTopicCheckedChanged, saveFollowedTopics)
|
||||||
|
}
|
||||||
|
is ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
items(uiState.feed) { _: NewsResource ->
|
||||||
|
// TODO: News item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The topic selection items
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.TopicSelection(
|
||||||
|
uiState: ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection,
|
||||||
|
onTopicCheckedChanged: (Int, Boolean) -> Unit,
|
||||||
|
saveFollowedTopics: () -> Unit
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FlowRow(
|
||||||
|
mainAxisSpacing = 8.dp,
|
||||||
|
crossAxisSpacing = 8.dp,
|
||||||
|
modifier = Modifier.padding(horizontal = 40.dp)
|
||||||
|
) {
|
||||||
|
uiState.selectedTopics.forEach { (topic, isSelected) ->
|
||||||
|
key(topic.id) {
|
||||||
|
// TODO: Add toggleable semantics
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onTopicCheckedChanged(topic.id, !isSelected)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
colors = if (isSelected) {
|
||||||
|
ButtonDefaults.buttonColors()
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.outlinedButtonColors()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = topic.name.uppercase(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = saveFollowedTopics,
|
||||||
|
enabled = uiState.canSaveSelectedTopics,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 40.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.done))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForYouScreenLoading() {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.Loading,
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForYouScreenTopicSelection() {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to true,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PopulatedFeed() {
|
||||||
|
ForYouScreen(
|
||||||
|
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
onTopicCheckedChanged = { _, _ -> },
|
||||||
|
saveFollowedTopics = {}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.foryou
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.NewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.ui.saveable
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ForYouViewModel @Inject constructor(
|
||||||
|
private val topicsRepository: TopicsRepository,
|
||||||
|
private val newsRepository: NewsRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val followedTopicsStateFlow = topicsRepository.getFollowedTopicIdsStream()
|
||||||
|
.map { followedTopics ->
|
||||||
|
if (followedTopics.isEmpty()) {
|
||||||
|
FollowedTopicsState.None
|
||||||
|
} else {
|
||||||
|
FollowedTopicsState.FollowedTopics(
|
||||||
|
topics = followedTopics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = FollowedTopicsState.Unknown
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-progress set of topics to be selected, persisted through process death with a
|
||||||
|
* [SavedStateHandle].
|
||||||
|
*/
|
||||||
|
private var inProgressTopicSelection by savedStateHandle.saveable {
|
||||||
|
mutableStateOf<Set<Int>>(emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
val uiState: StateFlow<ForYouFeedUiState> = combine(
|
||||||
|
followedTopicsStateFlow,
|
||||||
|
topicsRepository.getTopicsStream(),
|
||||||
|
snapshotFlow { inProgressTopicSelection },
|
||||||
|
) { followedTopicsUserState, availableTopics, inProgressTopicSelection ->
|
||||||
|
when (followedTopicsUserState) {
|
||||||
|
// If we don't know the current selection state, just emit loading.
|
||||||
|
FollowedTopicsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
|
||||||
|
// If the user has followed topics, use those followed topics to populate the feed
|
||||||
|
is FollowedTopicsState.FollowedTopics -> {
|
||||||
|
newsRepository.getNewsResourcesStream(
|
||||||
|
filterTopicIds = followedTopicsUserState.topics
|
||||||
|
)
|
||||||
|
.map { feed ->
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
|
||||||
|
feed = feed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the user hasn't followed topics yet, show the topic selection, as well as a
|
||||||
|
// realtime populated feed based on those in-progress topic selections.
|
||||||
|
FollowedTopicsState.None -> {
|
||||||
|
newsRepository.getNewsResourcesStream(
|
||||||
|
filterTopicIds = inProgressTopicSelection
|
||||||
|
)
|
||||||
|
.map { feed ->
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = availableTopics.map { topic ->
|
||||||
|
topic to (topic.id in inProgressTopicSelection)
|
||||||
|
},
|
||||||
|
feed = feed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flatten the feed flows.
|
||||||
|
// As the selected topics and topic state changes, this will cancel the old feed monitoring
|
||||||
|
// and start the new one.
|
||||||
|
.flatMapLatest { it }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = ForYouFeedUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun updateTopicSelection(topicId: Int, isChecked: Boolean) {
|
||||||
|
withMutableSnapshot {
|
||||||
|
inProgressTopicSelection =
|
||||||
|
// Update the in-progress selection based on whether the topic id was checked
|
||||||
|
if (isChecked) {
|
||||||
|
inProgressTopicSelection + topicId
|
||||||
|
} else {
|
||||||
|
inProgressTopicSelection - topicId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveFollowedTopics() {
|
||||||
|
if (inProgressTopicSelection.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sealed hierarchy for the user's current followed topics state.
|
||||||
|
*/
|
||||||
|
private sealed interface FollowedTopicsState {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state is unknown (hasn't loaded yet)
|
||||||
|
*/
|
||||||
|
object Unknown : FollowedTopicsState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user hasn't followed any topics yet.
|
||||||
|
*/
|
||||||
|
object None : FollowedTopicsState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has followed the given (non-empty) set of [topics].
|
||||||
|
*/
|
||||||
|
data class FollowedTopics(
|
||||||
|
val topics: Set<Int>,
|
||||||
|
) : FollowedTopicsState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sealed hierarchy describing the for you screen state.
|
||||||
|
*/
|
||||||
|
sealed interface ForYouFeedUiState {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen is still loading.
|
||||||
|
*/
|
||||||
|
object Loading : ForYouFeedUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loaded with a populated [feed] of [NewsResource]s.
|
||||||
|
*/
|
||||||
|
sealed interface PopulatedFeed : ForYouFeedUiState {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of news resources contained in this [PopulatedFeed].
|
||||||
|
*/
|
||||||
|
val feed: List<NewsResource>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed, along with a list of topics that can be selected.
|
||||||
|
*/
|
||||||
|
data class FeedWithTopicSelection(
|
||||||
|
val selectedTopics: List<Pair<Topic, Boolean>>,
|
||||||
|
override val feed: List<NewsResource>
|
||||||
|
) : PopulatedFeed {
|
||||||
|
val canSaveSelectedTopics: Boolean = selectedTopics.any { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just the feed.
|
||||||
|
*/
|
||||||
|
data class FeedWithoutTopicSelection(
|
||||||
|
override val feed: List<NewsResource>
|
||||||
|
) : PopulatedFeed
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_package = "com.google.samples.apps.nowinandroid.data";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
message UserPreferences {
|
||||||
|
repeated int32 followed_topic_ids = 1;
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* 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.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.CorruptionException
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UserPreferencesSerializerTest {
|
||||||
|
private val userPreferencesSerializer = UserPreferencesSerializer()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultUserPreferences_isEmpty() {
|
||||||
|
assertEquals(
|
||||||
|
userPreferences {
|
||||||
|
// Default value
|
||||||
|
},
|
||||||
|
userPreferencesSerializer.defaultValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writingAndReadingUserPreferences_outputsCorrectValue() = runTest {
|
||||||
|
val expectedUserPreferences = userPreferences {
|
||||||
|
followedTopicIds.add(0)
|
||||||
|
followedTopicIds.add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
expectedUserPreferences.writeTo(outputStream)
|
||||||
|
|
||||||
|
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
|
||||||
|
|
||||||
|
val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expectedUserPreferences,
|
||||||
|
actualUserPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = CorruptionException::class)
|
||||||
|
fun readingInvalidUserPreferences_throwsCorruptionException() = runTest {
|
||||||
|
userPreferencesSerializer.readFrom(ByteArrayInputStream(byteArrayOf(0)))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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.testutil
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.Description
|
||||||
|
import org.junit.runners.model.Statement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [TestRule] that initializes the main dispatcher to [dispatcher], which defaults to a
|
||||||
|
* [StandardTestDispatcher].
|
||||||
|
*/
|
||||||
|
class TestDispatcherRule(
|
||||||
|
private val dispatcher: CoroutineDispatcher = StandardTestDispatcher()
|
||||||
|
) : TestRule {
|
||||||
|
override fun apply(base: Statement, description: Description): Statement =
|
||||||
|
object : Statement() {
|
||||||
|
override fun evaluate() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
try {
|
||||||
|
base.evaluate()
|
||||||
|
} finally {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 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.testutil
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.NewsResource
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class TestNewsRepository : NewsRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backing hot flow for the list of topics ids for testing.
|
||||||
|
*/
|
||||||
|
private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
|
||||||
|
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = newsResourcesFlow
|
||||||
|
|
||||||
|
override fun getNewsResourcesStream(
|
||||||
|
filterTopicIds: Set<Int>
|
||||||
|
): Flow<List<NewsResource>> =
|
||||||
|
getNewsResourcesStream().map { newsResources ->
|
||||||
|
newsResources.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test-only API to allow controlling the list of news resources from tests.
|
||||||
|
*/
|
||||||
|
fun sendNewsResources(newsResources: List<NewsResource>) {
|
||||||
|
newsResourcesFlow.tryEmit(newsResources)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* 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.testutil
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
|
class TestTopicsRepository : TopicsRepository {
|
||||||
|
/**
|
||||||
|
* The backing hot flow for the list of followed topic ids for testing.
|
||||||
|
*/
|
||||||
|
private val _followedTopicIds: MutableSharedFlow<Set<Int>> =
|
||||||
|
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backing hot flow for the list of topics ids for testing.
|
||||||
|
*/
|
||||||
|
private val topicsFlow: MutableSharedFlow<List<Topic>> =
|
||||||
|
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
override fun getTopicsStream(): Flow<List<Topic>> = topicsFlow
|
||||||
|
|
||||||
|
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) {
|
||||||
|
_followedTopicIds.tryEmit(followedTopicIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFollowedTopicIdsStream(): Flow<Set<Int>> = _followedTopicIds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test-only API to allow controlling the list of topics from tests.
|
||||||
|
*/
|
||||||
|
fun sendTopics(topics: List<Topic>) {
|
||||||
|
topicsFlow.tryEmit(topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test-only API to allow querying the current followed topics.
|
||||||
|
*/
|
||||||
|
fun getCurrentFollowedTopics(): Set<Int>? = _followedTopicIds.replayCache.firstOrNull()
|
||||||
|
}
|
@ -0,0 +1,266 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.foryou
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.samples.apps.nowinandroid.data.news.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.testutil.TestDispatcherRule
|
||||||
|
import com.google.samples.apps.nowinandroid.testutil.TestNewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.testutil.TestTopicsRepository
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ForYouViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = TestDispatcherRule()
|
||||||
|
|
||||||
|
private val topicsRepository = TestTopicsRepository()
|
||||||
|
private val newsRepository = TestNewsRepository()
|
||||||
|
private lateinit var viewModel: ForYouViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
viewModel = ForYouViewModel(
|
||||||
|
topicsRepository = topicsRepository,
|
||||||
|
newsRepository = newsRepository,
|
||||||
|
savedStateHandle = SavedStateHandle()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsInitiallyLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
assertEquals(ForYouFeedUiState.Loading, awaitItem())
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
assertEquals(ForYouFeedUiState.Loading, awaitItem())
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsLoadingWhenTopicsAreLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
assertEquals(ForYouFeedUiState.Loading, awaitItem())
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsLoadingWhenNewsResourcesAreLoading() = runTest {
|
||||||
|
viewModel.uiState.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsTopicSelectionAfterLoadingEmptyFollowedTopics() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
newsRepository.sendNewsResources(emptyList())
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateIsWithoutTopicSelectionAfterLoadingFollowedTopics() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(setOf(0, 1))
|
||||||
|
newsRepository.sendNewsResources(emptyList())
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
newsRepository.sendNewsResources(emptyList())
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.updateTopicSelection(1, isChecked = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to true,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
newsRepository.sendNewsResources(emptyList())
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.updateTopicSelection(1, isChecked = true)
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.updateTopicSelection(1, isChecked = false)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
|
||||||
|
selectedTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
) to false,
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
) to false
|
||||||
|
),
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topicSelectionUpdatesAfterSavingTopics() = runTest {
|
||||||
|
viewModel.uiState
|
||||||
|
.test {
|
||||||
|
awaitItem()
|
||||||
|
topicsRepository.sendTopics(sampleTopics)
|
||||||
|
topicsRepository.setFollowedTopicIds(emptySet())
|
||||||
|
newsRepository.sendNewsResources(emptyList())
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.updateTopicSelection(1, isChecked = true)
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
viewModel.saveFollowedTopics()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
|
||||||
|
feed = emptyList()
|
||||||
|
),
|
||||||
|
awaitItem()
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1), topicsRepository.getCurrentFollowedTopics())
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sampleTopics = listOf(
|
||||||
|
Topic(
|
||||||
|
id = 0,
|
||||||
|
name = "Headlines",
|
||||||
|
description = ""
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 1,
|
||||||
|
name = "UI",
|
||||||
|
description = ""
|
||||||
|
),
|
||||||
|
Topic(
|
||||||
|
id = 2,
|
||||||
|
name = "Tools",
|
||||||
|
description = ""
|
||||||
|
)
|
||||||
|
)
|
Loading…
Reference in new issue