Change-Id: Iee5e8bd0932d1b68770aaa1e23fa660451109601pull/2/head
parent
dc4af7e6f7
commit
d945a007c6
@ -0,0 +1 @@
|
||||
/build
|
@ -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.
|
||||
*/
|
||||
plugins {
|
||||
id("nowinandroid.android.library")
|
||||
id("nowinandroid.android.library.compose")
|
||||
id("nowinandroid.android.library.jacoco")
|
||||
kotlin("kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
id("nowinandroid.spotless")
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-model"))
|
||||
implementation(project(":core-ui"))
|
||||
implementation(project(":core-data"))
|
||||
implementation(project(":core-common"))
|
||||
|
||||
testImplementation(project(":core-testing"))
|
||||
androidTestImplementation(project(":core-testing"))
|
||||
|
||||
implementation(libs.coil.kt)
|
||||
implementation(libs.coil.kt.compose)
|
||||
|
||||
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)
|
||||
|
||||
// TODO : Remove this dependency once we upgrade to Android Studio Dolphin b/228889042
|
||||
// These dependencies are currently necessary to render Compose previews
|
||||
debugImplementation(libs.androidx.customview.poolingcontainer)
|
||||
|
||||
// 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,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2022 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.google.samples.apps.nowinandroid.feature.author">
|
||||
|
||||
</manifest>
|
@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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.author
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons.Filled
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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 coil.compose.AsyncImage
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
|
||||
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
|
||||
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
|
||||
import com.google.samples.apps.nowinandroid.feature.author.AuthorUiState.Loading
|
||||
import com.google.samples.apps.nowinandroid.feature.author.R.string
|
||||
|
||||
@Composable
|
||||
fun AuthorRoute(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AuthorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState: AuthorScreenUiState by viewModel.uiState.collectAsState()
|
||||
|
||||
AuthorScreen(
|
||||
authorState = uiState.authorState,
|
||||
newsState = uiState.newsState,
|
||||
modifier = modifier,
|
||||
onBackClick = onBackClick,
|
||||
onFollowClick = viewModel::followAuthorToggle,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@VisibleForTesting
|
||||
@Composable
|
||||
internal fun AuthorScreen(
|
||||
authorState: AuthorUiState,
|
||||
newsState: NewsUiState,
|
||||
onBackClick: () -> Unit,
|
||||
onFollowClick: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Spacer(
|
||||
// TODO: Replace with windowInsetsTopHeight after
|
||||
// https://issuetracker.google.com/issues/230383055
|
||||
Modifier.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
|
||||
)
|
||||
)
|
||||
}
|
||||
when (authorState) {
|
||||
Loading -> {
|
||||
item {
|
||||
LoadingWheel(
|
||||
modifier = modifier,
|
||||
contentDesc = stringResource(id = string.author_loading),
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthorUiState.Error -> {
|
||||
TODO()
|
||||
}
|
||||
is AuthorUiState.Success -> {
|
||||
item {
|
||||
AuthorToolbar(
|
||||
onBackClick = onBackClick,
|
||||
onFollowClick = onFollowClick,
|
||||
uiState = authorState.followableAuthor,
|
||||
)
|
||||
}
|
||||
authorBody(
|
||||
author = authorState.followableAuthor.author,
|
||||
news = newsState
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(
|
||||
// TODO: Replace with windowInsetsBottomHeight after
|
||||
// https://issuetracker.google.com/issues/230383055
|
||||
Modifier.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.authorBody(
|
||||
author: Author,
|
||||
news: NewsUiState
|
||||
) {
|
||||
item {
|
||||
AuthorHeader(author)
|
||||
}
|
||||
|
||||
authorCards(news)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorHeader(author: Author) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.size(216.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.clip(CircleShape)
|
||||
.padding(bottom = 12.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
model = author.imageUrl,
|
||||
contentDescription = "Author profile picture",
|
||||
)
|
||||
Text(author.name, style = MaterialTheme.typography.displayMedium)
|
||||
if (author.bio.isNotEmpty()) {
|
||||
Text(
|
||||
text = author.bio,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.authorCards(news: NewsUiState) {
|
||||
when (news) {
|
||||
is NewsUiState.Success -> {
|
||||
newsResourceCardItems(
|
||||
items = news.news,
|
||||
newsResourceMapper = { it },
|
||||
isBookmarkedMapper = { /* TODO */ false },
|
||||
onToggleBookmark = { /* TODO */ },
|
||||
itemModifier = Modifier.padding(24.dp)
|
||||
)
|
||||
}
|
||||
is NewsUiState.Loading -> item {
|
||||
LoadingWheel(contentDesc = "Loading news") // TODO
|
||||
}
|
||||
else -> item {
|
||||
Text("Error") // TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorToolbar(
|
||||
uiState: FollowableAuthor,
|
||||
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
|
||||
NiaFilterChip(
|
||||
checked = selected,
|
||||
onCheckedChange = onFollowClick,
|
||||
) {
|
||||
if (selected) {
|
||||
Text(stringResource(id = string.author_following))
|
||||
} else {
|
||||
Text(stringResource(id = string.author_not_following))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AuthorBodyPreview() {
|
||||
MaterialTheme {
|
||||
LazyColumn {
|
||||
authorBody(
|
||||
author = Author(
|
||||
id = "0",
|
||||
name = "Android Dev",
|
||||
bio = "Works on Compose",
|
||||
twitter = "dev",
|
||||
mediumPage = "",
|
||||
imageUrl = "",
|
||||
),
|
||||
news = NewsUiState.Success(emptyList())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.author
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
|
||||
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 AuthorViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authorsRepository: AuthorsRepository,
|
||||
newsRepository: NewsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val authorId: String = checkNotNull(
|
||||
savedStateHandle[AuthorDestinationsArgs.AUTHOR_ID_ARG]
|
||||
)
|
||||
|
||||
// Observe the followed authors, as they could change over time.
|
||||
private val followedAuthorIdsStream: Flow<Result<Set<String>>> =
|
||||
authorsRepository.getFollowedAuthorIdsStream().asResult()
|
||||
|
||||
// Observe author information
|
||||
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream(
|
||||
id = authorId
|
||||
).asResult()
|
||||
|
||||
// Observe the News for this author
|
||||
private val newsStream: Flow<Result<List<NewsResource>>> =
|
||||
newsRepository.getNewsResourcesStream(
|
||||
filterAuthorIds = setOf(element = authorId),
|
||||
filterTopicIds = emptySet()
|
||||
).asResult()
|
||||
|
||||
val uiState: StateFlow<AuthorScreenUiState> =
|
||||
combine(
|
||||
followedAuthorIdsStream,
|
||||
author,
|
||||
newsStream
|
||||
) { followedAuthorsResult, authorResult, newsResult ->
|
||||
val author: AuthorUiState =
|
||||
if (authorResult is Result.Success && followedAuthorsResult is Result.Success) {
|
||||
val followed = followedAuthorsResult.data.contains(authorId)
|
||||
AuthorUiState.Success(
|
||||
followableAuthor = FollowableAuthor(
|
||||
author = authorResult.data,
|
||||
isFollowed = followed
|
||||
)
|
||||
)
|
||||
} else if (
|
||||
authorResult is Result.Loading || followedAuthorsResult is Result.Loading
|
||||
) {
|
||||
AuthorUiState.Loading
|
||||
} else {
|
||||
AuthorUiState.Error
|
||||
}
|
||||
|
||||
val news: NewsUiState = when (newsResult) {
|
||||
is Result.Success -> NewsUiState.Success(newsResult.data)
|
||||
is Result.Loading -> NewsUiState.Loading
|
||||
is Result.Error -> NewsUiState.Error
|
||||
}
|
||||
|
||||
AuthorScreenUiState(author, news)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = AuthorScreenUiState(AuthorUiState.Loading, NewsUiState.Loading)
|
||||
)
|
||||
|
||||
fun followAuthorToggle(followed: Boolean) {
|
||||
viewModelScope.launch {
|
||||
authorsRepository.toggleFollowedAuthorId(authorId, followed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AuthorUiState {
|
||||
data class Success(val followableAuthor: FollowableAuthor) : AuthorUiState
|
||||
object Error : AuthorUiState
|
||||
object Loading : AuthorUiState
|
||||
}
|
||||
|
||||
sealed interface NewsUiState {
|
||||
data class Success(val news: List<NewsResource>) : NewsUiState
|
||||
object Error : NewsUiState
|
||||
object Loading : NewsUiState
|
||||
}
|
||||
|
||||
data class AuthorScreenUiState(
|
||||
val authorState: AuthorUiState,
|
||||
val newsState: NewsUiState
|
||||
)
|
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.author
|
||||
|
||||
import com.google.samples.apps.nowinandroid.feature.author.AuthorDestinationsArgs.AUTHOR_ID_ARG
|
||||
import com.google.samples.apps.nowinandroid.feature.author.InterestsScreens.AUTHOR_SCREEN
|
||||
|
||||
object AuthorDestinations {
|
||||
const val AUTHOR_ROUTE = "$AUTHOR_SCREEN/{$AUTHOR_ID_ARG}"
|
||||
}
|
||||
|
||||
object AuthorDestinationsArgs {
|
||||
const val AUTHOR_ID_ARG = "authorId"
|
||||
}
|
||||
|
||||
object InterestsScreens {
|
||||
const val AUTHOR_SCREEN = "author"
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2022 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
<string name="author">Author</string>
|
||||
<string name="author_loading">Loading author</string>
|
||||
<string name="author_following">FOLLOWING</string>
|
||||
<string name="author_not_following">NOT FOLLOWING</string>
|
||||
</resources>
|
Loading…
Reference in new issue