Bug: 213876743 Tests: Local + UI tests + Navigation test Change-Id: I6c521695d6b777084a6255c6d62623a4def83063pull/2/head
parent
9ebea8ad29
commit
553d152844
@ -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