From 2f760fa9dae7ce644705927543e59d1d8423fa20 Mon Sep 17 00:00:00 2001 From: Yang Date: Thu, 30 Nov 2023 02:08:13 +1100 Subject: [PATCH] Home screen UI. --- android/app/build.gradle.kts | 5 +- .../kstreamlined/android/di/AppModule.kt | 4 + android/common-ui/feed/build.gradle.kts | 6 +- .../common/ui/feed/DisplayableFeedItem.kt | 19 + .../android/common/ui/feed/KotlinBlogCard.kt | 135 +++++ .../common/ui/feed/KotlinWeeklyCard.kt | 48 +- .../common/ui/feed/KotlinYouTubeCard.kt | 185 +++++++ .../common/ui/feed/TalkingKotlinCard.kt | 145 ++++++ .../android/designsystem/component/Chip.kt | 63 +++ .../android/designsystem/component/Divider.kt | 48 ++ .../designsystem/component/IconButton.kt | 49 ++ .../designsystem/component/TopNavBar.kt | 190 +++++++ .../foundation/color/KSColorScheme.kt | 44 +- .../designsystem/foundation/icon/KSIcons.kt | 2 + android/feature/common/build.gradle.kts | 3 - .../common/src/main/res/values/strings.xml | 3 - android/feature/home/build.gradle.kts | 3 + .../android/feature/home/HomeScreen.kt | 492 ++++++++++++++---- .../feature/home/component/FeedFilterChip.kt | 50 +- .../feature/home/component/SyncButton.kt | 99 ++-- .../home/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 3 +- .../kstreamlined/graphql/FeedEntries.graphql | 2 - .../datasource/mapper/FeedEntryMappers.kt | 2 - .../datasource/mapper/FeedEntryMappersTest.kt | 4 - .../kmp/feed/datasource/model/FeedEntry.kt | 4 +- .../kmp/feed/datasource/FakeFeedData.kt | 7 - kmp/model/build.gradle.kts | 10 + .../kstreamlined/kmp/model/feed/FeedItem.kt | 15 +- 29 files changed, 1406 insertions(+), 237 deletions(-) create mode 100644 android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/DisplayableFeedItem.kt create mode 100644 android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinBlogCard.kt create mode 100644 android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinYouTubeCard.kt create mode 100644 android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/TalkingKotlinCard.kt create mode 100644 android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Chip.kt create mode 100644 android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/TopNavBar.kt delete mode 100644 android/feature/common/src/main/res/values/strings.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 050fae01..a56c3250 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -19,7 +19,6 @@ plugins { id("kstreamlined.android.application.compose") id("kstreamlined.ksp") id("com.google.dagger.hilt.android") - id("com.google.gms.google-services") apply false id("com.google.firebase.firebase-perf") id("com.google.firebase.crashlytics") id("com.google.firebase.appdistribution") @@ -251,10 +250,12 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + // Image loading + implementation(libs.coil.svg) + // SQLDelight implementation(libs.sqldelight.androidDriver) - // LeakCanary debugImplementation(libs.leakcanary.android) implementation(libs.leakcanary.plumber) diff --git a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/di/AppModule.kt b/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/di/AppModule.kt index 5d8621f0..44bd1525 100644 --- a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/di/AppModule.kt +++ b/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/di/AppModule.kt @@ -3,6 +3,7 @@ package io.github.reactivecircus.kstreamlined.android.di import android.content.Context import android.os.Build import coil.ImageLoader +import coil.decode.SvgDecoder import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,6 +19,9 @@ object AppModule { @Singleton fun imageLoader(@ApplicationContext context: Context): ImageLoader { return ImageLoader.Builder(context) + .components { + add(SvgDecoder.Factory()) + } .crossfade(enable = true) // only enable hardware bitmaps on API 28+. See: https://github.com/coil-kt/coil/issues/159 .allowHardware(enable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) diff --git a/android/common-ui/feed/build.gradle.kts b/android/common-ui/feed/build.gradle.kts index 2cd6cd49..130d955d 100644 --- a/android/common-ui/feed/build.gradle.kts +++ b/android/common-ui/feed/build.gradle.kts @@ -5,9 +5,6 @@ plugins { android { namespace = "io.github.reactivecircus.kstreamlined.android.common.ui.feed" - buildFeatures { - androidResources = true - } } androidComponents { @@ -24,4 +21,7 @@ dependencies { // Compose implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui.tooling) + + // Image loading + implementation(libs.coil) } diff --git a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/DisplayableFeedItem.kt b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/DisplayableFeedItem.kt new file mode 100644 index 00000000..506fd81b --- /dev/null +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/DisplayableFeedItem.kt @@ -0,0 +1,19 @@ +package io.github.reactivecircus.kstreamlined.android.common.ui.feed + +import androidx.compose.runtime.Immutable +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem + +@Immutable +public data class DisplayableFeedItem( + val value: T, + val displayablePublishTime: String, +) + +public fun T.toDisplayable( + displayablePublishTime: String, +): DisplayableFeedItem { + return DisplayableFeedItem( + value = this, + displayablePublishTime = displayablePublishTime, + ) +} diff --git a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinBlogCard.kt b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinBlogCard.kt new file mode 100644 index 00000000..88ac5319 --- /dev/null +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinBlogCard.kt @@ -0,0 +1,135 @@ +package io.github.reactivecircus.kstreamlined.android.common.ui.feed + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.component.IconButton +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkAdd +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkFill +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import kotlinx.datetime.toInstant + +@Composable +public fun KotlinBlogCard( + item: DisplayableFeedItem, + onItemClick: (FeedItem.KotlinBlog) -> Unit, + onSaveButtonClick: (FeedItem.KotlinBlog) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { onItemClick(item.value) }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.container, + contentColor = KSTheme.colorScheme.onBackground, + ) { + Column { + AsyncImage( + model = item.value.featuredImageUrl, + contentDescription = item.value.title, + modifier = Modifier + .fillMaxWidth() + .height(ImageHeight), + contentScale = ContentScale.FillWidth, + ) + + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 8.dp, + top = 24.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.value.title, + style = KSTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp), + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.displayablePublishTime, + style = KSTheme.typography.bodyMedium, + color = KSTheme.colorScheme.onBackgroundVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + if (item.value.savedForLater) { + KSIcons.BookmarkFill + } else { + KSIcons.BookmarkAdd + }, + contentDescription = null, + onClick = { onSaveButtonClick(item.value) }, + modifier = Modifier, + ) + } + } + } + } +} + +private val ImageHeight = 200.dp + +@Composable +@ThemePreviews +private fun PreviewKotlinBlogCard_unsaved() { + KSTheme { + Surface { + KotlinBlogCard( + item = FeedItem.KotlinBlog( + id = "1", + title = "Kotlin Multiplatform Development Roadmap for 2024", + publishTime = "2023-11-16T11:59:46Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = false, + featuredImageUrl = "", + ).toDisplayable("Moments ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewKotlinBlogCard_saved() { + KSTheme { + Surface { + KotlinBlogCard( + item = FeedItem.KotlinBlog( + id = "1", + title = "Kotlin Multiplatform Development Roadmap for 2024", + publishTime = "2023-11-16T11:59:46Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = true, + featuredImageUrl = "", + ).toDisplayable("Moments ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} diff --git a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinWeeklyCard.kt b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinWeeklyCard.kt index 851fb539..b1c4d998 100644 --- a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinWeeklyCard.kt +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinWeeklyCard.kt @@ -5,10 +5,13 @@ 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.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews @@ -20,21 +23,33 @@ import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.ico import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkFill import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import kotlinx.datetime.toInstant @Composable public fun KotlinWeeklyCard( - item: FeedItem.KotlinWeekly, + item: DisplayableFeedItem, onItemClick: (FeedItem.KotlinWeekly) -> Unit, onSaveButtonClick: (FeedItem.KotlinWeekly) -> Unit, modifier: Modifier = Modifier, ) { + val brush = Brush.horizontalGradient( + colors = listOf( + KSTheme.colorScheme.primaryDark, + KSTheme.colorScheme.primaryLight, + ), + ) Surface( - onClick = { onItemClick(item) }, - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = KSTheme.colorScheme.primary, + onClick = { onItemClick(item.value) }, + modifier = modifier + .drawBehind { + drawRoundRect( + brush = brush, + cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx()), + ) + } + .fillMaxWidth(), + color = Color.Transparent, contentColor = KSTheme.colorScheme.onPrimary, - elevation = 4.dp, ) { Row( modifier = Modifier.padding(vertical = 24.dp), @@ -44,27 +59,28 @@ public fun KotlinWeeklyCard( modifier = Modifier .weight(1f) .padding(start = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = item.title, + text = item.value.title, style = KSTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = item.publishTime, + text = item.displayablePublishTime, style = KSTheme.typography.bodyMedium, + color = KSTheme.colorScheme.onPrimaryVariant, ) } IconButton( - if (item.savedForLater) { + if (item.value.savedForLater) { KSIcons.BookmarkFill } else { KSIcons.BookmarkAdd }, contentDescription = null, - onClick = { onSaveButtonClick(item) }, + onClick = { onSaveButtonClick(item.value) }, modifier = Modifier.padding(end = 8.dp), ) } @@ -80,10 +96,10 @@ private fun PreviewKotlinWeeklyCard_unsaved() { item = FeedItem.KotlinWeekly( id = "1", title = "Kotlin Weekly #381", - publishTime = "Moments ago", + publishTime = "2023-11-19T09:13:00Z".toInstant(), contentUrl = "contentUrl", savedForLater = false, - ), + ).toDisplayable(displayablePublishTime = "3 hours ago"), onItemClick = {}, onSaveButtonClick = {}, modifier = Modifier.padding(24.dp), @@ -101,10 +117,10 @@ private fun PreviewKotlinWeeklyCard_saved() { item = FeedItem.KotlinWeekly( id = "1", title = "Kotlin Weekly #381", - publishTime = "3 hours ago", + publishTime = "2023-11-19T09:13:00Z".toInstant(), contentUrl = "contentUrl", savedForLater = true, - ), + ).toDisplayable(displayablePublishTime = "3 hours ago"), onItemClick = {}, onSaveButtonClick = {}, modifier = Modifier.padding(24.dp), diff --git a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinYouTubeCard.kt b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinYouTubeCard.kt new file mode 100644 index 00000000..e88a72e0 --- /dev/null +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinYouTubeCard.kt @@ -0,0 +1,185 @@ +package io.github.reactivecircus.kstreamlined.android.common.ui.feed + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Icon +import io.github.reactivecircus.kstreamlined.android.designsystem.component.IconButton +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkAdd +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkFill +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import kotlinx.datetime.toInstant + +@OptIn(ExperimentalFoundationApi::class) +@Composable +public fun KotlinYouTubeCard( + item: DisplayableFeedItem, + onItemClick: (FeedItem.KotlinYouTube) -> Unit, + onSaveButtonClick: (FeedItem.KotlinYouTube) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { onItemClick(item.value) }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.secondary, + contentColor = KSTheme.colorScheme.onSecondary, + ) { + Column { + Box(contentAlignment = Alignment.Center) { + AsyncImage( + model = item.value.thumbnailUrl, + contentDescription = item.value.title, + modifier = Modifier + .fillMaxWidth() + .height(ImageHeight) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.FillWidth, + ) + PlayIconOverlay() + } + + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 8.dp, + top = 24.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.value.title, + style = KSTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier + .padding(end = 8.dp) + .basicMarquee( + iterations = 1, + velocity = 80.dp, + ), + text = item.value.description, + style = KSTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.displayablePublishTime, + style = KSTheme.typography.bodyMedium, + color = KSTheme.colorScheme.onSecondaryVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + if (item.value.savedForLater) { + KSIcons.BookmarkFill + } else { + KSIcons.BookmarkAdd + }, + contentDescription = null, + onClick = { onSaveButtonClick(item.value) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +private fun PlayIconOverlay( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = KSIcons.PlayArrow, + contentDescription = null, + modifier = Modifier + .padding(8.dp) + .size(40.dp), + ) + } +} + +private val ImageHeight = 200.dp + +@Composable +@ThemePreviews +private fun PreviewKotlinYouTubeCard_unsaved() { + KSTheme { + Surface { + KotlinYouTubeCard( + item = FeedItem.KotlinYouTube( + id = "1", + title = "The State of Kotlin Multiplatform", + publishTime = "2023-11-21T18:47:47Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = false, + thumbnailUrl = "", + description = "JetBrains Kotlin Multiplatform (KMP) is an open-source technology", + ).toDisplayable("3 days ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewKotlinYouTubeCard_saved() { + KSTheme { + Surface { + KotlinYouTubeCard( + item = FeedItem.KotlinYouTube( + id = "1", + title = "The State of Kotlin Multiplatform", + publishTime = "2023-11-21T18:47:47Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = true, + thumbnailUrl = "", + description = "JetBrains Kotlin Multiplatform (KMP) is an open-source technology", + ).toDisplayable("3 days ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} diff --git a/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/TalkingKotlinCard.kt b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/TalkingKotlinCard.kt new file mode 100644 index 00000000..5f15e142 --- /dev/null +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/TalkingKotlinCard.kt @@ -0,0 +1,145 @@ +package io.github.reactivecircus.kstreamlined.android.common.ui.feed + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.component.IconButton +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkAdd +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkFill +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import kotlinx.datetime.toInstant + +@Composable +public fun TalkingKotlinCard( + item: DisplayableFeedItem, + onItemClick: (FeedItem.TalkingKotlin) -> Unit, + onSaveButtonClick: (FeedItem.TalkingKotlin) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { onItemClick(item.value) }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.tertiary, + contentColor = KSTheme.colorScheme.onTertiary, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = item.value.podcastLogoUrl, + contentDescription = null, + modifier = Modifier + .padding(8.dp) + .size(80.dp), + contentScale = ContentScale.Fit, + ) + Column( + modifier = Modifier.padding( + end = 8.dp, + top = 24.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = item.value.title, + style = KSTheme.typography.titleLarge, + modifier = Modifier.padding(end = 8.dp), + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.displayablePublishTime, + style = KSTheme.typography.bodyMedium, + color = KSTheme.colorScheme.onTertiaryVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + if (item.value.savedForLater) { + KSIcons.BookmarkFill + } else { + KSIcons.BookmarkAdd + }, + contentDescription = null, + onClick = { onSaveButtonClick(item.value) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +@ThemePreviews +private fun PreviewTalkingKotlinCard_unsaved() { + KSTheme { + Surface { + TalkingKotlinCard( + item = FeedItem.TalkingKotlin( + id = "1", + title = "Making Multiplatform Better", + publishTime = "2023-09-18T22:00:00Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = false, + podcastLogoUrl = "", + tags = listOf( + "Kotlin", + "KMP", + "Kotlin Multiplatform", + "Coroutines", + ), + ).toDisplayable("Moments ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewTalkingKotlinCard_saved() { + KSTheme { + Surface { + TalkingKotlinCard( + item = FeedItem.TalkingKotlin( + id = "1", + title = "Making Multiplatform Better", + publishTime = "2023-09-18T22:00:00Z".toInstant(), + contentUrl = "contentUrl", + savedForLater = true, + podcastLogoUrl = "", + tags = listOf( + "Kotlin", + "KMP", + "Kotlin Multiplatform", + "Coroutines", + ), + ).toDisplayable("Moments ago"), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Chip.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Chip.kt new file mode 100644 index 00000000..aa621a84 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Chip.kt @@ -0,0 +1,63 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons + +@Composable +public fun Chip( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + color: Color = KSTheme.colorScheme.container, + contentColor: Color = KSTheme.colorScheme.onBackground, + content: @Composable RowScope.() -> Unit, +) { + Surface( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = CircleShape, + color = color, + contentColor = contentColor, + ) { + Row( + modifier = Modifier.padding( + vertical = 8.dp, + horizontal = 12.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} + +@Composable +@ThemePreviews +private fun PreviewChip() { + KSTheme { + Surface { + Chip( + onClick = {}, + modifier = Modifier.padding(8.dp), + ) { + Text( + text = "Chip", + style = KSTheme.typography.labelLarge, + ) + Icon(KSIcons.ArrowDown, contentDescription = null) + } + } + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Divider.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Divider.kt index 0a6dcaf0..57d0ba95 100644 --- a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Divider.kt +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Divider.kt @@ -1,11 +1,19 @@ package io.github.reactivecircus.kstreamlined.android.designsystem.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme import androidx.compose.material3.HorizontalDivider as MaterialHorizontalDivider import androidx.compose.material3.VerticalDivider as MaterialVerticalDivider @@ -37,3 +45,43 @@ public fun VerticalDivider( color = color, ) } + +@Composable +@ThemePreviews +private fun PreviewHorizontalDivider() { + KSTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = "item 1", style = KSTheme.typography.titleMedium) + HorizontalDivider( + modifier = Modifier.width(120.dp), + ) + Text(text = "item 2", style = KSTheme.typography.titleMedium) + } + } + } +} + +@Composable +@ThemePreviews +private fun PreviewVerticalDivider() { + KSTheme { + Surface { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = "item 1", style = KSTheme.typography.titleMedium) + VerticalDivider( + modifier = Modifier.height(120.dp), + ) + Text(text = "item 2", style = KSTheme.typography.titleMedium) + } + } + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/IconButton.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/IconButton.kt index 8de45fcf..d11ecf6e 100644 --- a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/IconButton.kt +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/IconButton.kt @@ -7,6 +7,7 @@ package io.github.reactivecircus.kstreamlined.android.designsystem.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -25,8 +26,11 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.BookmarkAdd +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons @Composable @NonRestartableComposable @@ -149,3 +153,48 @@ private val DefaultContainerSize = 40.dp private val LargeIconSize = 32.dp private val LargeContainerSize = 48.dp + +@Composable +@ThemePreviews +private fun PreviewIconButton() { + KSTheme { + Surface { + IconButton( + KSIcons.BookmarkAdd, + contentDescription = null, + onClick = {}, + modifier = Modifier.padding(8.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewLargeIconButton() { + KSTheme { + Surface { + LargeIconButton( + KSIcons.Close, + contentDescription = null, + onClick = {}, + modifier = Modifier.padding(8.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewFilledIconButton() { + KSTheme { + Surface { + FilledIconButton( + KSIcons.Settings, + contentDescription = null, + onClick = {}, + modifier = Modifier.padding(8.dp), + ) + } + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/TopNavBar.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/TopNavBar.kt new file mode 100644 index 00000000..661bca5c --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/TopNavBar.kt @@ -0,0 +1,190 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons + +@Composable +public fun TopNavBar( + title: String, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + elevation: Dp = 2.dp, + navigationIcon: @Composable (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + bottomRow: @Composable (RowScope.() -> Unit)? = null, +) { + Surface( + modifier = modifier, + elevation = elevation, + color = KSTheme.colorScheme.background, + contentColor = KSTheme.colorScheme.primary, + ) { + Box( + modifier = Modifier.padding(contentPadding) + ) { + Column( + modifier = Modifier.padding(vertical = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (navigationIcon != null) { + Spacer(modifier = Modifier.width(16.dp)) + navigationIcon() + Spacer(modifier = Modifier.width(8.dp)) + } else { + Spacer(modifier = Modifier.width(24.dp)) + } + + GradientTitle( + text = title, + modifier = Modifier.weight(1f) + ) + + actions() + + Spacer(modifier = Modifier.width(16.dp)) + } + + if (bottomRow != null) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 24.dp), + content = bottomRow, + ) + } + } + } + } +} + +@Composable +private fun GradientTitle( + text: String, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + val gradient = KSTheme.colorScheme.gradient + val brush = remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return LinearGradientShader( + colors = gradient, + from = Offset(0f, size.height), + to = Offset(size.width * GradientHorizontalScale, 0f), + ) + } + } + } + Text( + text = text, + style = KSTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, + brush = brush, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private const val GradientHorizontalScale = 1.3f + +@Composable +@ThemePreviews +private fun PreviewTopNavBar() { + KSTheme { + Surface { + TopNavBar( + title = "Title", + actions = { + FilledIconButton( + KSIcons.Settings, + contentDescription = null, + onClick = {}, + ) + }, + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewTopNavBar_withBottomRow() { + KSTheme { + Surface { + TopNavBar( + title = "Title", + actions = { + FilledIconButton( + KSIcons.Settings, + contentDescription = null, + onClick = {}, + ) + }, + bottomRow = { + Chip( + onClick = {}, + contentColor = KSTheme.colorScheme.primary, + ) { + Text( + text = "Button".uppercase(), + style = KSTheme.typography.labelMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, + ) + ) + Icon(KSIcons.ArrowDown, contentDescription = null) + } + } + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewTopNavBar_withNavigationIcon() { + KSTheme { + Surface { + TopNavBar( + title = "Title", + navigationIcon = { + LargeIconButton( + KSIcons.Close, + contentDescription = null, + onClick = {}, + ) + }, + ) + } + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/color/KSColorScheme.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/color/KSColorScheme.kt index 9772a392..cf400890 100644 --- a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/color/KSColorScheme.kt +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/color/KSColorScheme.kt @@ -9,14 +9,20 @@ import androidx.compose.ui.graphics.Color @Immutable public class KSColorScheme internal constructor( public val primary: Color, + public val primaryDark: Color, + public val primaryLight: Color, public val onPrimary: Color, + public val onPrimaryVariant: Color, public val secondary: Color, + public val onSecondary: Color, + public val onSecondaryVariant: Color, public val tertiary: Color, + public val onTertiary: Color, + public val onTertiaryVariant: Color, public val background: Color, public val onBackground: Color, public val onBackgroundVariant: Color, public val container: Color, - public val primaryOnContainer: Color, public val outline: Color, public val gradient: List, public val isDark: Boolean, @@ -24,50 +30,66 @@ public class KSColorScheme internal constructor( internal val LightColorScheme = KSColorScheme( primary = Palette.CandyGrapeFizz50, + primaryDark = Palette.CandyGrapeFizz40, + primaryLight = Palette.CandyGrapeFizz70, onPrimary = Palette.Lavender50, - secondary = Palette.PromiscuousPink50, - tertiary = Palette.Watermelonade, + onPrimaryVariant = Palette.Lavender40, + secondary = Palette.Watermelonade40, + onSecondary = Palette.Watermelonade70, + onSecondaryVariant = Palette.Watermelonade60, + tertiary = Color.White, + onTertiary = Palette.MysteriousDepths10, + onTertiaryVariant = Palette.MysteriousDepths30, background = Palette.Lavender50, onBackground = Palette.MysteriousDepths10, onBackgroundVariant = Palette.MysteriousDepths30, container = Palette.CandyDreams20, - primaryOnContainer = Palette.CandyGrapeFizz50, outline = Palette.CandyDreams10, gradient = listOf( Palette.CandyGrapeFizz50, Palette.PromiscuousPink50, - Palette.Watermelonade, + Palette.Watermelonade50, ), isDark = false, ) internal val DarkColorScheme = KSColorScheme( - primary = Palette.CandyGrapeFizz40, + primary = Palette.CandyGrapeFizz60, + primaryDark = Palette.CandyGrapeFizz40, + primaryLight = Palette.CandyGrapeFizz70, onPrimary = Palette.Lavender40, - secondary = Palette.PromiscuousPink50, + onPrimaryVariant = Palette.Lavender30, background = Palette.MysteriousDepths10, onBackground = Palette.Lavender50, onBackgroundVariant = Palette.Lavender30, - tertiary = Palette.Watermelonade, + secondary = Palette.Watermelonade40, + onSecondary = Palette.Watermelonade70, + onSecondaryVariant = Palette.Watermelonade60, + tertiary = Color.White, + onTertiary = Palette.MysteriousDepths10, + onTertiaryVariant = Palette.MysteriousDepths30, container = Palette.MysteriousDepths20, - primaryOnContainer = Palette.CandyGrapeFizz60, outline = Palette.MysteriousDepths30, gradient = listOf( Palette.CandyGrapeFizz50, Palette.PromiscuousPink50, - Palette.Watermelonade, + Palette.Watermelonade50, ), isDark = true, ) private object Palette { + val CandyGrapeFizz70 = Color(0xFF_947BFF) val CandyGrapeFizz60 = Color(0xFF_8968FF) val CandyGrapeFizz50 = Color(0xFF_7F52FF) val CandyGrapeFizz40 = Color(0xFF_6E46DE) val PromiscuousPink50 = Color(0xFF_C711E1) - val Watermelonade = Color(0xFF_E44855) + val Watermelonade70 = Color(0xFF_FFEEED) + val Watermelonade60 = Color(0xFF_FFDDDC) + val Watermelonade50 = Color(0xFF_E44855) + val Watermelonade40 = Color(0xFF_C63D49) val Lavender50 = Color(0xFF_E7DBFF) val Lavender40 = Color(0xFF_DBD0F2) diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/KSIcons.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/KSIcons.kt index e1f6cece..84cb177f 100644 --- a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/KSIcons.kt +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/KSIcons.kt @@ -3,6 +3,7 @@ package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.ic import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Settings import androidx.compose.ui.graphics.vector.ImageVector @@ -10,4 +11,5 @@ public object KSIcons { public val Close: ImageVector = Icons.Rounded.Close public val Settings: ImageVector = Icons.Rounded.Settings public val ArrowDown: ImageVector = Icons.Rounded.KeyboardArrowDown + public val PlayArrow: ImageVector = Icons.Rounded.PlayArrow } diff --git a/android/feature/common/build.gradle.kts b/android/feature/common/build.gradle.kts index 23050ab3..108d04f7 100644 --- a/android/feature/common/build.gradle.kts +++ b/android/feature/common/build.gradle.kts @@ -7,9 +7,6 @@ plugins { android { namespace = "io.github.reactivecircus.kstreamlined.android.feature.common" - buildFeatures { - androidResources = true - } } androidComponents { diff --git a/android/feature/common/src/main/res/values/strings.xml b/android/feature/common/src/main/res/values/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/android/feature/common/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/android/feature/home/build.gradle.kts b/android/feature/home/build.gradle.kts index 8a1d172f..a20f6375 100644 --- a/android/feature/home/build.gradle.kts +++ b/android/feature/home/build.gradle.kts @@ -5,6 +5,9 @@ plugins { android { namespace = "io.github.reactivecircus.kstreamlined.android.feature.home" + buildFeatures { + androidResources = true + } } dependencies { diff --git a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/HomeScreen.kt b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/HomeScreen.kt index 2fe920ca..41855f35 100644 --- a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/HomeScreen.kt +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/HomeScreen.kt @@ -4,13 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape @@ -20,25 +20,25 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.LinearGradientShader -import androidx.compose.ui.graphics.Shader -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import io.github.reactivecircus.kstreamlined.android.common.ui.feed.KotlinBlogCard import io.github.reactivecircus.kstreamlined.android.common.ui.feed.KotlinWeeklyCard +import io.github.reactivecircus.kstreamlined.android.common.ui.feed.KotlinYouTubeCard +import io.github.reactivecircus.kstreamlined.android.common.ui.feed.TalkingKotlinCard +import io.github.reactivecircus.kstreamlined.android.common.ui.feed.toDisplayable import io.github.reactivecircus.kstreamlined.android.designsystem.component.FilledIconButton import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text +import io.github.reactivecircus.kstreamlined.android.designsystem.component.TopNavBar import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons import io.github.reactivecircus.kstreamlined.android.feature.home.component.FeedFilterChip import io.github.reactivecircus.kstreamlined.android.feature.home.component.SyncButton import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem import kotlinx.coroutines.delay +import kotlinx.datetime.toInstant @Composable public fun HomeScreen( @@ -49,90 +49,416 @@ public fun HomeScreen( .fillMaxSize() .background(KSTheme.colorScheme.background), ) { - Surface( - elevation = 2.dp, + TopNavBar( + title = stringResource(id = R.string.title_home), + contentPadding = WindowInsets.statusBars.asPaddingValues(), + actions = { + FilledIconButton( + KSIcons.Settings, + contentDescription = null, + onClick = {}, + ) + }, + bottomRow = { + FeedFilterChip( + selectedFeedCount = 4, + onClick = {}, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + var syncing by remember { mutableStateOf(false) } + LaunchedEffect(syncing) { + if (syncing) { + @Suppress("MagicNumber") + (delay(500)) + syncing = false + } + } + SyncButton( + onClick = { syncing = true }, + syncing = syncing, + ) + } + ) + + @Suppress("MaxLineLength") + LazyColumn( + contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - // TODO move to :designsystem - Column( - modifier = Modifier.padding(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), - verticalAlignment = Alignment.CenterVertically, - ) { - GradientTitle( - text = "KStreamlined", - modifier = Modifier.padding(horizontal = 24.dp) + item { + Text( + text = "This week", + style = KSTheme.typography.titleMedium, + color = KSTheme.colorScheme.onBackgroundVariant, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinWeekly( + id = "https://mailchi.mp/kotlinweekly/kotlin-weekly-382", + title = "Kotlin Weekly #382", + publishTime = "2023-11-26T11:14:09Z".toInstant(), + contentUrl = "https://mailchi.mp/kotlinweekly/kotlin-weekly-382", + savedForLater = false, + ).toDisplayable(displayablePublishTime = "Moments ago") ) + } + KotlinWeeklyCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - Spacer(modifier = Modifier.weight(1f)) + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinYouTube( + id = "yt:video:zE2LIAUisRI", + title = "Getting Started With KMP: Build Apps for iOS and Android With Shared Logic and Native UIs", + publishTime = "2023-11-23T17:00:38Z".toInstant(), + contentUrl = "https://www.youtube.com/watch?v=zE2LIAUisRI", + savedForLater = false, + thumbnailUrl = "https://i3.ytimg.com/vi/zE2LIAUisRI/hqdefault.jpg", + description = "During this webinar, we will get you up to speed with the basics of Kotlin Multiplatform. The webinar will cover what's involved in configuring your development environment, creating a Multiplatform Mobile project, and progressing to a more elaborate project that shares the data and networking layers.", + ).toDisplayable("2 hours ago"), + ) + } + KotlinYouTubeCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - FilledIconButton( - KSIcons.Settings, - contentDescription = null, - onClick = {}, - iconTint = KSTheme.colorScheme.primaryOnContainer, + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinYouTube( + id = "yt:video:bz4cQeaXmsI", + title = "The State of Kotlin Multiplatform", + publishTime = "2023-11-21T18:47:47Z".toInstant(), + contentUrl = "https://www.youtube.com/watch?v=bz4cQeaXmsI", + savedForLater = false, + thumbnailUrl = "https://i3.ytimg.com/vi/bz4cQeaXmsI/hqdefault.jpg", + description = "JetBrains Kotlin Multiplatform (KMP) is an open-source technology designed for flexible cross-platform development. It allows you to develop apps for Android, iOS, desktop, web, and server-side and efficiently reuse code across them, all while retaining the benefits of native programming. After 8 years of development, KMP has been refined into a production-ready technology and is going Stable, which means now is a great time to start using it in your project.", + ).toDisplayable("Yesterday"), ) + } + KotlinYouTubeCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - Spacer(modifier = Modifier.width(16.dp)) + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinWeekly( + id = "https://mailchi.mp/kotlinweekly/kotlin-weekly-381", + title = "Kotlin Weekly #381", + publishTime = "2023-11-19T09:13:00Z".toInstant(), + contentUrl = "https://mailchi.mp/kotlinweekly/kotlin-weekly-381", + savedForLater = false, + ).toDisplayable(displayablePublishTime = "4 days ago") + ) } + KotlinWeeklyCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 24.dp), - ) { - FeedFilterChip(selectedFeedCount = 4) + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinBlog( + id = "https://blog.jetbrains.com/?post_type=kotlin&p=405553", + title = "Kotlin Multiplatform Development Roadmap for 2024", + publishTime = "2023-11-16T11:59:46Z".toInstant(), + contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-development-roadmap-for-2024/", + savedForLater = false, + featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/kmp_roadmap_720.png", + ).toDisplayable(displayablePublishTime = "6 days ago") + ) + } + KotlinBlogCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - var syncing by remember { mutableStateOf(false) } + item { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Last week", + style = KSTheme.typography.titleMedium, + color = KSTheme.colorScheme.onBackgroundVariant, + ) + } - SyncButton( - onClick = { syncing = true }, - syncing = syncing, + item { + var item by remember { + mutableStateOf( + FeedItem.TalkingKotlin( + id = "https://talkingkotlin.com/http4k-chronicles", + title = "http4k Chronicles", + publishTime = "2023-11-13T23:00:00Z".toInstant(), + contentUrl = "https://talkingkotlin.com/http4k-chronicles/", + savedForLater = false, + podcastLogoUrl = "https://talkingkotlin.com/images/kotlin_talking_logo.png", + tags = listOf( + "Kotlin", + "KMP", + "Kotlin Multiplatform", + "http4k", + ), + ).toDisplayable(displayablePublishTime = "14 Nov 2023") ) + } + TalkingKotlinCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } - LaunchedEffect(syncing) { - if (syncing) { - @Suppress("MagicNumber") - (delay(500)) - syncing = false - } - } + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinBlog( + id = "https://blog.jetbrains.com/?post_type=blog&p=404245", + title = "Amper – Improving the Build Tooling User Experience", + publishTime = "2023-11-09T10:07:24Z".toInstant(), + contentUrl = "https://blog.jetbrains.com/blog/2023/11/09/amper-improving-the-build-tooling-user-experience/", + savedForLater = false, + featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/Blog-Featured-1280x720-2x-1-2.png", + ).toDisplayable(displayablePublishTime = "09 Nov 2023") + ) } + KotlinBlogCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinBlog( + id = "https://blog.jetbrains.com/?post_type=kotlin&p=403389", + title = "Welcome Fleet with Kotlin Multiplatform Tooling", + publishTime = "2023-11-07T14:24:34Z".toInstant(), + contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-tooling-in-fleet/", + savedForLater = false, + featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/Blog-Featured-1280x720-2x.png", + ).toDisplayable(displayablePublishTime = "07 Nov 2023") + ) + } + KotlinBlogCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) } - } - LazyColumn( - contentPadding = PaddingValues(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { item { + Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Today", + text = "Earlier", style = KSTheme.typography.titleMedium, color = KSTheme.colorScheme.onBackgroundVariant, ) } + item { var item by remember { mutableStateOf( FeedItem.KotlinWeekly( - id = "1", - title = "Kotlin Weekly #381", - publishTime = "Moments ago", - contentUrl = "contentUrl", + id = "https://mailchi.mp/kotlinweekly/kotlin-weekly-379", + title = "Kotlin Weekly #379", + publishTime = "2023-11-05T08:13:58Z".toInstant(), + contentUrl = "https://mailchi.mp/kotlinweekly/kotlin-weekly-379", savedForLater = false, - ) + ).toDisplayable(displayablePublishTime = "05 Nov 2023") ) } KotlinWeeklyCard( item = item, onItemClick = {}, onSaveButtonClick = { - item = item.copy(savedForLater = !item.savedForLater) + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinBlog( + id = "https://blog.jetbrains.com/?post_type=kotlin&p=401216", + title = "Compose Multiplatform 1.5.10 – The Perfect Time To Get Started", + publishTime = "2023-11-02T12:01:27Z".toInstant(), + contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/compose-multiplatform-1-5-10-release/", + savedForLater = false, + featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/compose-featured_blog_1280x720.png", + ).toDisplayable(displayablePublishTime = "02 Nov 2023") + ) + } + KotlinBlogCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinYouTube( + id = "yt:video:Ol_96CHKqg8", + title = "What's new in Kotlin 1.9.20", + publishTime = "2023-11-01T12:44:00Z".toInstant(), + contentUrl = "https://www.youtube.com/watch?v=Ol_96CHKqg8", + savedForLater = false, + thumbnailUrl = "https://i4.ytimg.com/vi/Ol_96CHKqg8/hqdefault.jpg", + description = "The Kotlin 1.9.20 release is out, and the K2 compiler for all the targets is now in Beta.", + ).toDisplayable("Yesterday"), + ) + } + KotlinYouTubeCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.KotlinBlog( + id = "https://blog.jetbrains.com/?post_type=kotlin&p=401121", + title = "Kotlin Multiplatform Is Stable and Production-Ready", + publishTime = "2023-11-01T11:31:28Z".toInstant(), + contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-stable/", + savedForLater = false, + featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/DSGN-17931-Banners-for-1.9.20-release-and-KMP-Stable-annoucement_Blog-Social-share-image-1280x720-1-1.png", + ).toDisplayable(displayablePublishTime = "01 Nov 2023") + ) + } + KotlinBlogCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) + }, + ) + } + + item { + var item by remember { + mutableStateOf( + FeedItem.TalkingKotlin( + id = "https://talkingkotlin.com/Compose-Multiplatform-in-Production-at-Instabee", + title = "Compose Multiplatform in Production on iOS at Instabee", + publishTime = "2023-08-09T22:00:00Z".toInstant(), + contentUrl = "https://talkingkotlin.com/Compose-Multiplatform-in-Production-at-Instabee/", + savedForLater = false, + podcastLogoUrl = "https://talkingkotlin.com/images/kotlin_talking_logo.png", + tags = listOf( + "Kotlin", + "Compose", + "Compose Multiplatform", + "Kotlin Multiplatform", + ), + ).toDisplayable(displayablePublishTime = "09 Aug 2023") + ) + } + TalkingKotlinCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy( + value = it.copy( + savedForLater = !it.savedForLater + ) + ) }, ) } @@ -167,14 +493,6 @@ public fun HomeScreen( ) {} } - item { - Text( - text = "This week", - style = KSTheme.typography.titleMedium, - color = KSTheme.colorScheme.onBackgroundVariant, - ) - } - item { Surface( modifier = Modifier @@ -201,7 +519,7 @@ public fun HomeScreen( .fillMaxWidth() .height(200.dp), shape = RoundedCornerShape(16.dp), - color = KSTheme.colorScheme.secondary, + color = KSTheme.colorScheme.tertiary, ) {} } @@ -211,39 +529,9 @@ public fun HomeScreen( .fillMaxWidth() .height(200.dp), shape = RoundedCornerShape(16.dp), - color = KSTheme.colorScheme.tertiary, + color = KSTheme.colorScheme.secondary, ) {} } } } } - -@Composable -private fun GradientTitle( - text: String, - modifier: Modifier = Modifier, -) { - val gradient = KSTheme.colorScheme.gradient - val brush = remember { - object : ShaderBrush() { - override fun createShader(size: Size): Shader { - return LinearGradientShader( - colors = gradient, - from = Offset(0f, size.height), - to = Offset(size.width * GradientHorizontalScale, 0f), - ) - } - } - } - Text( - text = text, - style = KSTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.ExtraBold, - brush = brush, - ), - modifier = modifier, - maxLines = 1, - ) -} - -private const val GradientHorizontalScale = 1.3f diff --git a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/FeedFilterChip.kt b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/FeedFilterChip.kt index aa5e16e0..7ce0dc45 100644 --- a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/FeedFilterChip.kt +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/FeedFilterChip.kt @@ -1,49 +1,53 @@ package io.github.reactivecircus.kstreamlined.android.feature.home.component -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Chip import io.github.reactivecircus.kstreamlined.android.designsystem.component.Icon import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons +import io.github.reactivecircus.kstreamlined.android.feature.home.R @Composable internal fun FeedFilterChip( selectedFeedCount: Int, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Surface( - onClick = {}, + Chip( + onClick = onClick, modifier = modifier, - shape = CircleShape, - color = KSTheme.colorScheme.container, - contentColor = KSTheme.colorScheme.primaryOnContainer, + contentColor = KSTheme.colorScheme.primary, ) { - Row( - modifier = Modifier.padding( - vertical = 8.dp, - horizontal = 12.dp, + Text( + text = stringResource(id = R.string.feeds_selected, selectedFeedCount).uppercase(), + style = KSTheme.typography.labelMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "$selectedFeedCount Feeds selected".uppercase(), // TODO use string resource - style = KSTheme.typography.labelMedium.copy( - fontWeight = FontWeight.ExtraBold, - letterSpacing = 0.sp, - ), + ) + Icon(KSIcons.ArrowDown, contentDescription = null) + } +} + +@Composable +@ThemePreviews +private fun PreviewFeedFilterChip() { + KSTheme { + Surface { + FeedFilterChip( + selectedFeedCount = 4, + onClick = {}, + modifier = Modifier.padding(8.dp), ) - Icon(KSIcons.ArrowDown, contentDescription = null) } } } diff --git a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/SyncButton.kt b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/SyncButton.kt index 23941d65..6d41e76e 100644 --- a/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/SyncButton.kt +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/SyncButton.kt @@ -5,28 +5,28 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import io.github.reactivecircus.kstreamlined.android.designsystem.ThemePreviews +import io.github.reactivecircus.kstreamlined.android.designsystem.component.Chip import io.github.reactivecircus.kstreamlined.android.designsystem.component.Icon import io.github.reactivecircus.kstreamlined.android.designsystem.component.Surface import io.github.reactivecircus.kstreamlined.android.designsystem.component.Text import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.KSIcons import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon.Sync +import io.github.reactivecircus.kstreamlined.android.feature.home.R @Composable internal fun SyncButton( @@ -34,62 +34,65 @@ internal fun SyncButton( syncing: Boolean, modifier: Modifier = Modifier, ) { - Surface( + Chip( onClick = onClick, modifier = modifier, enabled = !syncing, - shape = CircleShape, - color = KSTheme.colorScheme.container, - contentColor = KSTheme.colorScheme.primaryOnContainer, + contentColor = KSTheme.colorScheme.primary, ) { - Row( - modifier = Modifier.padding( - vertical = 8.dp, - horizontal = 12.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - var currentRotation by remember { mutableStateOf(0f) } - val rotation = remember(syncing) { Animatable(currentRotation) } - LaunchedEffect(syncing) { - if (syncing) { + var currentRotation by remember { mutableFloatStateOf(0f) } + val rotation = remember(syncing) { Animatable(currentRotation) } + LaunchedEffect(syncing) { + if (syncing) { + rotation.animateTo( + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(AnimationDurationMillis, easing = LinearEasing), + ), + ) { + currentRotation = value + } + } else { + if (currentRotation > 0f) { rotation.animateTo( targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(AnimationDurationMillis, easing = LinearEasing), + animationSpec = tween( + durationMillis = AnimationDurationMillis, + easing = LinearOutSlowInEasing, ), ) { - currentRotation = value - } - } else { - if (currentRotation > 0f) { - rotation.animateTo( - targetValue = 360f, - animationSpec = tween( - durationMillis = AnimationDurationMillis, - easing = LinearOutSlowInEasing, - ), - ) { - currentRotation = 0f - } + currentRotation = 0f } } } - Icon( - KSIcons.Sync, - contentDescription = null, - modifier = Modifier.rotate(rotation.value), - ) - Text( - text = "Sync".uppercase(), - style = KSTheme.typography.labelMedium.copy( - fontWeight = FontWeight.ExtraBold, - letterSpacing = 0.sp, - ), - ) } + Icon( + KSIcons.Sync, + contentDescription = null, + modifier = Modifier.rotate(rotation.value), + ) + Text( + text = stringResource(id = R.string.sync).uppercase(), + style = KSTheme.typography.labelMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, + ), + ) } } private const val AnimationDurationMillis = 1000 + +@Composable +@ThemePreviews +private fun PreviewSyncButton() { + KSTheme { + Surface { + SyncButton( + onClick = {}, + syncing = true, + modifier = Modifier.padding(8.dp), + ) + } + } +} diff --git a/android/feature/home/src/main/res/values/strings.xml b/android/feature/home/src/main/res/values/strings.xml index 045e125f..2998fdf8 100644 --- a/android/feature/home/src/main/res/values/strings.xml +++ b/android/feature/home/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ + KStreamlined + %1$d Feeds selected + Sync diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f65414dc..07c25cd5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ wire = "4.9.3" detekt = "1.23.4" playPublisher = "3.8.6" fladle = "0.17.4" -nativeCoroutines = "1.0.0-ALPHA-22" +nativeCoroutines = "1.0.0-ALPHA-21" desugarJdkLibs = "2.0.4" leakcanary = "2.12" hilt = "2.48.1" @@ -109,6 +109,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidx-test-espresso" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "androidx-test-espresso" } coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } apollo-runtime = { module = "com.apollographql.apollo3:apollo-runtime", version.ref = "apollo" } apollo-normalizedCache = { module = "com.apollographql.apollo3:apollo-normalized-cache", version.ref = "apollo" } apollo-adapters = { module = "com.apollographql.apollo3:apollo-adapters", version.ref = "apollo" } diff --git a/kmp/feed-datasource/cloud/src/commonMain/graphql/io/github/reactivecircus/kstreamlined/graphql/FeedEntries.graphql b/kmp/feed-datasource/cloud/src/commonMain/graphql/io/github/reactivecircus/kstreamlined/graphql/FeedEntries.graphql index e56d5fb5..d32cdf53 100644 --- a/kmp/feed-datasource/cloud/src/commonMain/graphql/io/github/reactivecircus/kstreamlined/graphql/FeedEntries.graphql +++ b/kmp/feed-datasource/cloud/src/commonMain/graphql/io/github/reactivecircus/kstreamlined/graphql/FeedEntries.graphql @@ -6,7 +6,6 @@ query FeedEntriesQuery($filters: [FeedSourceKey!]) { contentUrl ... on KotlinBlog { featuredImageUrl - description } ... on KotlinYouTube { thumbnailUrl @@ -14,7 +13,6 @@ query FeedEntriesQuery($filters: [FeedSourceKey!]) { } ... on TalkingKotlin { podcastLogoUrl - tags } ... on KotlinWeekly { contentUrl diff --git a/kmp/feed-datasource/cloud/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappers.kt b/kmp/feed-datasource/cloud/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappers.kt index 920b2912..8d78ad8b 100644 --- a/kmp/feed-datasource/cloud/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappers.kt +++ b/kmp/feed-datasource/cloud/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappers.kt @@ -20,7 +20,6 @@ internal fun FeedEntriesQuery.KotlinBlogFeedEntry.asExternalModel(): FeedEntry.K publishTime = this.publishTime, contentUrl = this.contentUrl, featuredImageUrl = this.onKotlinBlog.featuredImageUrl, - description = this.onKotlinBlog.description, ) } @@ -42,7 +41,6 @@ internal fun FeedEntriesQuery.TalkingKotlinFeedEntry.asExternalModel(): FeedEntr publishTime = this.publishTime, contentUrl = this.contentUrl, podcastLogoUrl = this.onTalkingKotlin.podcastLogoUrl, - tags = this.onTalkingKotlin.tags, ) } diff --git a/kmp/feed-datasource/cloud/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappersTest.kt b/kmp/feed-datasource/cloud/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappersTest.kt index 9a994623..e60469cd 100644 --- a/kmp/feed-datasource/cloud/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappersTest.kt +++ b/kmp/feed-datasource/cloud/src/commonTest/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/mapper/FeedEntryMappersTest.kt @@ -22,7 +22,6 @@ class FeedEntryMappersTest { publishTime = "2022-01-01T00:00:00Z".toInstant() contentUrl = "https://blog.kotlinlang.org/post" featuredImageUrl = "https://blog.kotlinlang.org/image" - description = "A blog post about Kotlin" }, ) }.feedEntries.first() @@ -33,7 +32,6 @@ class FeedEntryMappersTest { publishTime = "2022-01-01T00:00:00Z".toInstant(), contentUrl = "https://blog.kotlinlang.org/post", featuredImageUrl = "https://blog.kotlinlang.org/image", - description = "A blog post about Kotlin", ) assertEquals(expectedFeedEntry, apolloFeedEntry.asExternalModel()) @@ -76,7 +74,6 @@ class FeedEntryMappersTest { publishTime = "2022-01-03T00:00:00Z".toInstant() contentUrl = "https://talkingkotlin.com/podcast" podcastLogoUrl = "https://talkingkotlin.com/podcast/logo" - tags = listOf("Kotlin", "Podcast") }, ) }.feedEntries.first() @@ -87,7 +84,6 @@ class FeedEntryMappersTest { publishTime = "2022-01-03T00:00:00Z".toInstant(), contentUrl = "https://talkingkotlin.com/podcast", podcastLogoUrl = "https://talkingkotlin.com/podcast/logo", - tags = listOf("Kotlin", "Podcast"), ) assertEquals(expectedFeedEntry, apolloFeedEntry.asExternalModel()) diff --git a/kmp/feed-datasource/common/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/model/FeedEntry.kt b/kmp/feed-datasource/common/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/model/FeedEntry.kt index 69eb8d8c..17c1f117 100644 --- a/kmp/feed-datasource/common/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/model/FeedEntry.kt +++ b/kmp/feed-datasource/common/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/model/FeedEntry.kt @@ -13,8 +13,7 @@ public sealed interface FeedEntry { override val title: String, override val publishTime: Instant, override val contentUrl: String, - val featuredImageUrl: String?, - val description: String, + val featuredImageUrl: String, ) : FeedEntry public data class KotlinYouTube( @@ -32,7 +31,6 @@ public sealed interface FeedEntry { override val publishTime: Instant, override val contentUrl: String, val podcastLogoUrl: String, - val tags: List, ) : FeedEntry public data class KotlinWeekly( diff --git a/kmp/feed-datasource/testing/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FakeFeedData.kt b/kmp/feed-datasource/testing/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FakeFeedData.kt index a9417482..3e103ead 100644 --- a/kmp/feed-datasource/testing/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FakeFeedData.kt +++ b/kmp/feed-datasource/testing/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/feed/datasource/FakeFeedData.kt @@ -36,7 +36,6 @@ public val FakeFeedEntries: List = listOf( publishTime = "2023-11-16T11:59:46Z".toInstant(), contentUrl = "https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-development-roadmap-for-2024/", featuredImageUrl = "https://blog.jetbrains.com/wp-content/uploads/2023/11/kmp_roadmap_720.png", - description = "With the recently achieved stability of Kotlin Multiplatform, development teams worldwide can now seamlessly and confidently adopt it in production. However, this is just the beginning for KMP and its ecosystem. To equip you with the best cross-platform development experience, JetBrains aims to deliver a host of further improvements to the core Kotlin Multiplatform technology, […]", ), FeedEntry.KotlinYouTube( id = "yt:video:bz4cQeaXmsI", @@ -52,12 +51,6 @@ public val FakeFeedEntries: List = listOf( publishTime = "2023-09-18T22:00:00Z".toInstant(), contentUrl = "https://talkingkotlin.com/making-multiplatform-better/", podcastLogoUrl = "https://talkingkotlin.com/images/kotlin_talking_logo.png", - tags = listOf( - "Kotlin", - "KMP", - "Kotlin Multiplatform", - "Coroutines", - ), ), FeedEntry.KotlinWeekly( id = "https://mailchi.mp/kotlinweekly/kotlin-weekly-381", diff --git a/kmp/model/build.gradle.kts b/kmp/model/build.gradle.kts index 693f10c3..95a772a5 100644 --- a/kmp/model/build.gradle.kts +++ b/kmp/model/build.gradle.kts @@ -1,3 +1,13 @@ plugins { id("kstreamlined.kmp.common") } + +kotlin { + sourceSets { + commonMain { + dependencies { + api(libs.kotlinx.datetime) + } + } + } +} diff --git a/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt index 80304bcc..ac03d3ed 100644 --- a/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt +++ b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt @@ -1,26 +1,27 @@ package io.github.reactivecircus.kstreamlined.kmp.model.feed +import kotlinx.datetime.Instant + public sealed interface FeedItem { public val id: String public val title: String - public val publishTime: String + public val publishTime: Instant public val contentUrl: String public val savedForLater: Boolean public data class KotlinBlog( override val id: String, override val title: String, - override val publishTime: String, + override val publishTime: Instant, override val contentUrl: String, override val savedForLater: Boolean, - val featuredImageUrl: String?, - val description: String, + val featuredImageUrl: String, ) : FeedItem public data class KotlinYouTube( override val id: String, override val title: String, - override val publishTime: String, + override val publishTime: Instant, override val contentUrl: String, override val savedForLater: Boolean, val thumbnailUrl: String, @@ -30,7 +31,7 @@ public sealed interface FeedItem { public data class TalkingKotlin( override val id: String, override val title: String, - override val publishTime: String, + override val publishTime: Instant, override val contentUrl: String, override val savedForLater: Boolean, val podcastLogoUrl: String, @@ -40,7 +41,7 @@ public sealed interface FeedItem { public data class KotlinWeekly( override val id: String, override val title: String, - override val publishTime: String, + override val publishTime: Instant, override val contentUrl: String, override val savedForLater: Boolean, ) : FeedItem