Bug: 213876743 Tests: Local + UI tests + Navigation test Change-Id: I6c521695d6b777084a6255c6d62623a4def83063pull/1837/head
parent
71cef7951b
commit
a50b0f5d6a
@ -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.result
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
|
||||||
|
sealed interface Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>
|
||||||
|
data class Error(val exception: Throwable? = null) : Result<Nothing>
|
||||||
|
object Loading : Result<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
|
||||||
|
return this
|
||||||
|
.map<T, Result<T>> {
|
||||||
|
Result.Success(it)
|
||||||
|
}
|
||||||
|
.onStart { emit(Result.Loading) }
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
/build
|
@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* 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 'com.android.library'
|
||||||
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
id 'dagger.hilt.android.plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk buildConfig.compileSdk
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk buildConfig.minSdk
|
||||||
|
targetSdk buildConfig.targetSdk
|
||||||
|
|
||||||
|
testInstrumentationRunner "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion libs.versions.androidxCompose.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core-model')
|
||||||
|
implementation project(':core-ui')
|
||||||
|
implementation project(':core-domain')
|
||||||
|
implementation project(':core-common')
|
||||||
|
|
||||||
|
testImplementation project(':core-testing')
|
||||||
|
androidTestImplementation project(':core-testing')
|
||||||
|
|
||||||
|
implementation libs.kotlinx.coroutines.android
|
||||||
|
implementation libs.kotlinx.datetime
|
||||||
|
|
||||||
|
implementation libs.androidx.hilt.navigation.compose
|
||||||
|
implementation libs.androidx.lifecycle.viewModelCompose
|
||||||
|
|
||||||
|
implementation libs.hilt.android
|
||||||
|
kapt libs.hilt.compiler
|
||||||
|
|
||||||
|
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
|
||||||
|
configurations.configureEach {
|
||||||
|
resolutionStrategy {
|
||||||
|
force libs.junit4
|
||||||
|
// Temporary workaround for https://issuetracker.google.com/174733673
|
||||||
|
force 'org.objenesis:objenesis:2.6'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.google.samples.apps.nowinandroid.feature.topic">
|
||||||
|
|
||||||
|
</manifest>
|
@ -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.feature.topic
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs.TOPIC_ID_ARG
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreens.TOPIC_SCREEN
|
||||||
|
|
||||||
|
object TopicDestinations {
|
||||||
|
const val TOPICS_ROUTE = "topics"
|
||||||
|
const val TOPIC_ROUTE = "$TOPIC_SCREEN/{$TOPIC_ID_ARG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
object TopicDestinationsArgs {
|
||||||
|
const val TOPIC_ID_ARG = "topicId"
|
||||||
|
}
|
||||||
|
|
||||||
|
object TopicScreens {
|
||||||
|
const val TOPIC_SCREEN = "topic"
|
||||||
|
}
|
@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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.feature.topic
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.Chip
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.icons.Icons.Filled
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
|
||||||
|
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.R.string
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopicRoute(
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: TopicViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState: TopicScreenUiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
TopicScreen(
|
||||||
|
topicState = uiState.topicState,
|
||||||
|
newsState = uiState.newsState,
|
||||||
|
modifier = modifier,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
onFollowClick = viewModel::followTopicToggle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopicScreen(
|
||||||
|
topicState: TopicUiState,
|
||||||
|
newsState: NewsUiState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onFollowClick: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
when (topicState) {
|
||||||
|
Loading ->
|
||||||
|
NiaLoadingIndicator(
|
||||||
|
modifier = modifier,
|
||||||
|
contentDesc = stringResource(id = string.topic_loading),
|
||||||
|
)
|
||||||
|
TopicUiState.Error -> TODO()
|
||||||
|
is TopicUiState.Success -> {
|
||||||
|
TopicToolbar(
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
onFollowClick = onFollowClick,
|
||||||
|
uiState = topicState.followableTopic
|
||||||
|
)
|
||||||
|
TopicBody(
|
||||||
|
name = topicState.followableTopic.topic.name,
|
||||||
|
description = topicState.followableTopic.topic.longDescription,
|
||||||
|
news = newsState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopicBody(name: String, description: String, news: NewsUiState) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
// TODO: Show icon if available
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(216.dp)
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.background(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color.Black, Color.White)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
Text(name, style = MaterialTheme.typography.displayMedium)
|
||||||
|
if (description.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TopicList(news, Modifier.padding(top = 24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopicList(news: NewsUiState, modifier: Modifier = Modifier) {
|
||||||
|
when (news) {
|
||||||
|
is NewsUiState.Success -> {
|
||||||
|
LazyColumn(modifier = modifier) {
|
||||||
|
items(news.news.size) { index ->
|
||||||
|
Text(news.news[index].title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NewsUiState.Loading -> {
|
||||||
|
NiaLoadingIndicator(contentDesc = "Loading news") // TODO
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text("Error") // TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun TopicBodyPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
TopicBody("Jetpack Compose", "Lorem ipsum maximum", NewsUiState.Success(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TopicToolbar(
|
||||||
|
uiState: FollowableTopic,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBackClick: () -> Unit = {},
|
||||||
|
onFollowClick: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { onBackClick() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(id = R.string.back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val selected = uiState.isFollowed
|
||||||
|
Chip(onClick = { onFollowClick(!selected) }) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Filled.Check,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text("FOLLOWING")
|
||||||
|
} else {
|
||||||
|
Text("NOT FOLLOWING")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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.feature.topic
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.core.result.Result
|
||||||
|
import com.google.samples.apps.nowinandroid.core.result.asResult
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TopicViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val topicsRepository: TopicsRepository,
|
||||||
|
newsRepository: NewsRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val topicId: Int = checkNotNull(savedStateHandle[TopicDestinationsArgs.TOPIC_ID_ARG])
|
||||||
|
|
||||||
|
// Observe the followed topics, as they could change over time.
|
||||||
|
private val followedTopicIdsStream: Flow<Result<Set<Int>>> =
|
||||||
|
topicsRepository.getFollowedTopicIdsStream().asResult()
|
||||||
|
|
||||||
|
// Observe topic information
|
||||||
|
private val topic: Flow<Result<Topic>> = topicsRepository.getTopic(topicId).asResult()
|
||||||
|
|
||||||
|
// Observe the News for this topic
|
||||||
|
private val newsStream: Flow<Result<List<NewsResource>>> =
|
||||||
|
newsRepository.getNewsResourcesStream(setOf(topicId)).asResult()
|
||||||
|
|
||||||
|
val uiState: StateFlow<TopicScreenUiState> =
|
||||||
|
combine(
|
||||||
|
followedTopicIdsStream,
|
||||||
|
topic,
|
||||||
|
newsStream
|
||||||
|
) { followedTopicsResult, topicResult, newsResult ->
|
||||||
|
val topic: TopicUiState =
|
||||||
|
if (topicResult is Result.Success && followedTopicsResult is Result.Success) {
|
||||||
|
val followed = followedTopicsResult.data.contains(topicId)
|
||||||
|
TopicUiState.Success(
|
||||||
|
followableTopic = FollowableTopic(
|
||||||
|
topic = topicResult.data,
|
||||||
|
isFollowed = followed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (
|
||||||
|
topicResult is Result.Loading || followedTopicsResult is Result.Loading
|
||||||
|
) {
|
||||||
|
TopicUiState.Loading
|
||||||
|
} else {
|
||||||
|
TopicUiState.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
val news: NewsUiState = when (newsResult) {
|
||||||
|
is Result.Success -> NewsUiState.Success(newsResult.data)
|
||||||
|
is Result.Loading -> NewsUiState.Loading
|
||||||
|
is Result.Error -> NewsUiState.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
TopicScreenUiState(topic, news)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = TopicScreenUiState(TopicUiState.Loading, NewsUiState.Loading)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun followTopicToggle(followed: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
topicsRepository.toggleFollowedTopicId(topicId, followed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface TopicUiState {
|
||||||
|
data class Success(val followableTopic: FollowableTopic) : TopicUiState
|
||||||
|
object Error : TopicUiState
|
||||||
|
object Loading : TopicUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface NewsUiState {
|
||||||
|
data class Success(val news: List<NewsResource>) : NewsUiState
|
||||||
|
object Error : NewsUiState
|
||||||
|
object Loading : NewsUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TopicScreenUiState(
|
||||||
|
val topicState: TopicUiState,
|
||||||
|
val newsState: NewsUiState
|
||||||
|
)
|
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright 2022 Google LLC
|
||||||
|
~
|
||||||
|
~ 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.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="topic">Topic</string>
|
||||||
|
<string name="topic_loading">Loading topic</string>
|
||||||
|
</resources>
|
Loading…
Reference in new issue