Screenshot: https://screenshot.googleplex.com/9K6C4NZMfMzCABE.png Change-Id: I32b0240910df6a953c8843895f3b7e22d5adc5depull/2/head
parent
ca73f5598f
commit
de2f07d1a4
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.model.data
|
||||
|
||||
/**
|
||||
* An [author] with the additional information for whether or not it is followed.
|
||||
*/
|
||||
data class FollowableAuthor(
|
||||
val author: Author,
|
||||
val isFollowed: Boolean
|
||||
)
|
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.testing.repository
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
class TestAuthorsRepository : AuthorsRepository {
|
||||
/**
|
||||
* The backing hot flow for the list of followed author ids for testing.
|
||||
*/
|
||||
private val _followedAuthorIds: MutableSharedFlow<Set<Int>> =
|
||||
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
/**
|
||||
* The backing hot flow for the list of author ids for testing.
|
||||
*/
|
||||
private val authorsFlow: MutableSharedFlow<List<Author>> =
|
||||
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
override fun getAuthorsStream(): Flow<List<Author>> = authorsFlow
|
||||
|
||||
override fun getFollowedAuthorIdsStream(): Flow<Set<Int>> = _followedAuthorIds
|
||||
|
||||
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<Int>) {
|
||||
_followedAuthorIds.tryEmit(followedAuthorIds)
|
||||
}
|
||||
|
||||
override suspend fun toggleFollowedAuthorId(followedAuthorId: Int, followed: Boolean) {
|
||||
getCurrentFollowedAuthors()?.let { current ->
|
||||
_followedAuthorIds.tryEmit(
|
||||
if (followed) current.plus(followedAuthorId)
|
||||
else current.minus(followedAuthorId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sync(): Boolean = true
|
||||
|
||||
/**
|
||||
* A test-only API to allow controlling the list of topics from tests.
|
||||
*/
|
||||
fun sendAuthors(authors: List<Author>) {
|
||||
authorsFlow.tryEmit(authors)
|
||||
}
|
||||
|
||||
/**
|
||||
* A test-only API to allow querying the current followed topics.
|
||||
*/
|
||||
fun getCurrentFollowedAuthors(): Set<Int>? = _followedAuthorIds.replayCache.firstOrNull()
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons.Filled
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FollowButton(
|
||||
following: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onFollowChange: ((Boolean) -> Unit)? = null,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
size: Dp = 32.dp,
|
||||
iconSize: Dp = size / 2,
|
||||
followingContentDescription: String? = null,
|
||||
notFollowingContentDescription: String? = null,
|
||||
) {
|
||||
val background = if (following) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
backgroundColor
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.followButton(onFollowChange, following, enabled, background, size),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (following) {
|
||||
Icon(
|
||||
imageVector = Filled.Done,
|
||||
contentDescription = followingContentDescription,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Filled.Add,
|
||||
contentDescription = notFollowingContentDescription,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.followButton(
|
||||
onFollowChange: ((Boolean) -> Unit)?,
|
||||
following: Boolean,
|
||||
enabled: Boolean,
|
||||
background: Color,
|
||||
size: Dp
|
||||
): Modifier = composed {
|
||||
val boxModifier = if (onFollowChange != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = 24.dp)
|
||||
this
|
||||
.toggleable(
|
||||
value = following,
|
||||
onValueChange = onFollowChange,
|
||||
enabled = enabled,
|
||||
role = Role.Checkbox,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
boxModifier
|
||||
.clip(CircleShape)
|
||||
.background(background)
|
||||
.size(size)
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.foryou
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.FollowButton
|
||||
|
||||
@Composable
|
||||
fun AuthorsCarousel(
|
||||
authors: List<FollowableAuthor>,
|
||||
onAuthorClick: (Int, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyRow(modifier) {
|
||||
items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
|
||||
AuthorItem(
|
||||
author = followableAuthor.author,
|
||||
following = followableAuthor.isFollowed,
|
||||
onAuthorClick = { following ->
|
||||
onAuthorClick(followableAuthor.author.id, following)
|
||||
},
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorItem(
|
||||
author: Author,
|
||||
following: Boolean,
|
||||
onAuthorClick: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val followDescription = if (following) {
|
||||
stringResource(id = R.string.following)
|
||||
} else {
|
||||
stringResource(id = R.string.not_following)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.toggleable(
|
||||
value = following,
|
||||
enabled = true,
|
||||
role = Role.Button,
|
||||
onValueChange = { newFollowing -> onAuthorClick(newFollowing) },
|
||||
)
|
||||
.sizeIn(maxWidth = 48.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
stateDescription = "$followDescription ${author.name}"
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
model = author.imageUrl,
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = null
|
||||
)
|
||||
FollowButton(
|
||||
following = following,
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
size = 20.dp,
|
||||
iconSize = 14.dp,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = author.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AuthorCarouselPreview() {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
AuthorsCarousel(
|
||||
authors = listOf(
|
||||
FollowableAuthor(
|
||||
Author(1, "Android Dev", "", "", ""),
|
||||
false
|
||||
),
|
||||
FollowableAuthor(
|
||||
Author(2, "Android Dev2", "", "", ""),
|
||||
true
|
||||
),
|
||||
FollowableAuthor(
|
||||
Author(3, "Android Dev3", "", "", ""),
|
||||
false
|
||||
)
|
||||
),
|
||||
onAuthorClick = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AuthorItemPreview() {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
AuthorItem(
|
||||
author = Author(0, "Android Dev", "", "", ""),
|
||||
following = true,
|
||||
onAuthorClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue