diff --git a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/KSActivity.kt b/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/KSActivity.kt index 2bc661c1..16b35cff 100644 --- a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/KSActivity.kt +++ b/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/KSActivity.kt @@ -5,15 +5,14 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import dagger.hilt.android.AndroidEntryPoint -import io.github.reactivecircus.kstreamlined.android.theme.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.feature.home.HomeScreen @AndroidEntryPoint class KSActivity : ComponentActivity() { @@ -28,15 +27,12 @@ class KSActivity : ComponentActivity() { setContent { KSTheme { val darkTheme = isSystemInDarkTheme() - val navigationBarColor = MaterialTheme.colorScheme.background.toArgb() + val navigationBarColor = KSTheme.colorScheme.background.toArgb() LaunchedEffect(darkTheme) { window.navigationBarColor = navigationBarColor } - Surface( - modifier = Modifier.fillMaxSize(), - ) { - } + HomeScreen(modifier = Modifier.navigationBarsPadding()) } } } 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 6e4dec6d..5d8621f0 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 @@ -19,7 +19,6 @@ object AppModule { fun imageLoader(@ApplicationContext context: Context): ImageLoader { return ImageLoader.Builder(context) .crossfade(enable = true) - // TODO default drawables for fallback, error, placeholder // 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) .build() diff --git a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/theme/Theme.kt b/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/theme/Theme.kt deleted file mode 100644 index 30d16a7f..00000000 --- a/android/app/src/main/java/io/github/reactivecircus/kstreamlined/android/theme/Theme.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.github.reactivecircus.kstreamlined.android.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -// TODO define color scheme - -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF7F52FF), - background = Color(0xFF070426), - surface = Color(0xFF070426), -) - -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF7F52FF), - background = Color(0xFFE7DBFF), - surface = Color(0xFFE7DBFF), -) - -@Composable -fun KSTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colorScheme = if (darkTheme) { - DarkColorScheme - } else { - LightColorScheme - } - - // TODO customize shapes and typography - MaterialTheme( - colorScheme = colorScheme, - content = content, - ) -} diff --git a/android/common-ui/feed/build.gradle.kts b/android/common-ui/feed/build.gradle.kts new file mode 100644 index 00000000..2cd6cd49 --- /dev/null +++ b/android/common-ui/feed/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("kstreamlined.android.library") + id("kstreamlined.android.library.compose") +} + +android { + namespace = "io.github.reactivecircus.kstreamlined.android.common.ui.feed" + buildFeatures { + androidResources = true + } +} + +androidComponents { + beforeVariants { + @Suppress("UnstableApiUsage") + it.androidTest.enable = false + } +} + +dependencies { + implementation(project(":designsystem")) + implementation(project(":kmp:model")) + + // Compose + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.tooling) +} diff --git a/android/common-ui/feed/src/main/AndroidManifest.xml b/android/common-ui/feed/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/android/common-ui/feed/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..851fb539 --- /dev/null +++ b/android/common-ui/feed/src/main/java/io/github/reactivecircus/kstreamlined/android/common/ui/feed/KotlinWeeklyCard.kt @@ -0,0 +1,114 @@ +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.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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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 + +@Composable +public fun KotlinWeeklyCard( + item: FeedItem.KotlinWeekly, + onItemClick: (FeedItem.KotlinWeekly) -> Unit, + onSaveButtonClick: (FeedItem.KotlinWeekly) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { onItemClick(item) }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.primary, + contentColor = KSTheme.colorScheme.onPrimary, + elevation = 4.dp, + ) { + Row( + modifier = Modifier.padding(vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = item.title, + style = KSTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = item.publishTime, + style = KSTheme.typography.bodyMedium, + ) + } + IconButton( + if (item.savedForLater) { + KSIcons.BookmarkFill + } else { + KSIcons.BookmarkAdd + }, + contentDescription = null, + onClick = { onSaveButtonClick(item) }, + modifier = Modifier.padding(end = 8.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewKotlinWeeklyCard_unsaved() { + KSTheme { + Surface { + KotlinWeeklyCard( + item = FeedItem.KotlinWeekly( + id = "1", + title = "Kotlin Weekly #381", + publishTime = "Moments ago", + contentUrl = "contentUrl", + savedForLater = false, + ), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewKotlinWeeklyCard_saved() { + KSTheme { + Surface { + KotlinWeeklyCard( + item = FeedItem.KotlinWeekly( + id = "1", + title = "Kotlin Weekly #381", + publishTime = "3 hours ago", + contentUrl = "contentUrl", + savedForLater = true, + ), + onItemClick = {}, + onSaveButtonClick = {}, + modifier = Modifier.padding(24.dp), + ) + } + } +} diff --git a/android/designsystem/build.gradle.kts b/android/designsystem/build.gradle.kts index 34d9ff82..7f4a28e7 100644 --- a/android/designsystem/build.gradle.kts +++ b/android/designsystem/build.gradle.kts @@ -14,6 +14,5 @@ dependencies { // Compose implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.foundation) - debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.ui.toolingPreview) + implementation(libs.androidx.compose.ui.tooling) } diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/ThemePreviews.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/ThemePreviews.kt new file mode 100644 index 00000000..de46c0d8 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/ThemePreviews.kt @@ -0,0 +1,8 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark") +public annotation class ThemePreviews 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 new file mode 100644 index 00000000..0a6dcaf0 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Divider.kt @@ -0,0 +1,39 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +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.foundation.KSTheme +import androidx.compose.material3.HorizontalDivider as MaterialHorizontalDivider +import androidx.compose.material3.VerticalDivider as MaterialVerticalDivider + +@Composable +@NonRestartableComposable +public fun HorizontalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = KSTheme.colorScheme.outline, +) { + MaterialHorizontalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} + +@Composable +@NonRestartableComposable +public fun VerticalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = KSTheme.colorScheme.outline, +) { + MaterialVerticalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Icon.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Icon.kt new file mode 100644 index 00000000..c9573bd4 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Icon.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Woolworths. All rights reserved. + */ + +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor +import androidx.compose.material3.Icon as MaterialIcon + +@Composable +@NonRestartableComposable +public fun Icon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + MaterialIcon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = modifier, + tint = tint, + ) +} 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 new file mode 100644 index 00000000..8de45fcf --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/IconButton.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022 Woolworths. All rights reserved. + */ + +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.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.remember +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.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +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.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor + +@Composable +@NonRestartableComposable +public fun IconButton( + imageVector: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + iconTint: Color = LocalContentColor.current, +) { + IconButtonImpl( + imageVector = imageVector, + contentDescription = contentDescription, + onClick = onClick, + modifier = modifier, + enabled = enabled, + iconTint = iconTint, + containerSize = DefaultContainerSize, + iconSize = DefaultIconSize, + ) +} + +@Composable +@NonRestartableComposable +public fun LargeIconButton( + imageVector: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + iconTint: Color = LocalContentColor.current, +) { + IconButtonImpl( + imageVector = imageVector, + contentDescription = contentDescription, + onClick = onClick, + modifier = modifier, + enabled = enabled, + iconTint = iconTint, + containerSize = LargeContainerSize, + iconSize = LargeIconSize, + ) +} + +@Composable +private fun IconButtonImpl( + imageVector: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean, + iconTint: Color, + containerSize: Dp, + iconSize: Dp, +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(containerSize) + .clip(CircleShape) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple( + bounded = false, + radius = containerSize / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.requiredSize(iconSize), + tint = iconTint, + ) + } +} + +@Composable +public fun FilledIconButton( + imageVector: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + containerColor: Color = KSTheme.colorScheme.container, + iconTint: Color = LocalContentColor.current, +) { + val interactionSource = remember { MutableInteractionSource() } + Surface( + onClick = onClick, + modifier = modifier.semantics { role = Role.Button }, + enabled = enabled, + shape = CircleShape, + color = containerColor, + contentColor = iconTint, + interactionSource = interactionSource, + ) { + Box( + modifier = Modifier.size(DefaultContainerSize), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.requiredSize(DefaultIconSize), + tint = iconTint, + ) + } + } +} + +private val DefaultIconSize = 24.dp +private val DefaultContainerSize = 40.dp + +private val LargeIconSize = 32.dp +private val LargeContainerSize = 48.dp diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Surface.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Surface.kt new file mode 100644 index 00000000..b7c334ff --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Surface.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Woolworths. All rights reserved. + */ + +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.KSTheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor +import androidx.compose.material3.Surface as MaterialSurface + +@Composable +@NonRestartableComposable +public fun Surface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + color: Color = KSTheme.colorScheme.background, + contentColor: Color = KSTheme.colorScheme.onBackground, + border: BorderStroke? = null, + elevation: Dp = 0.dp, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + MaterialSurface( + modifier = modifier, + shape = shape, + color = color, + contentColor = contentColor, + tonalElevation = elevation, + shadowElevation = elevation, + border = border, + content = content, + ) + } +} + +@Composable +@NonRestartableComposable +public fun Surface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = RectangleShape, + color: Color = KSTheme.colorScheme.background, + contentColor: Color = KSTheme.colorScheme.onBackground, + elevation: Dp = 0.dp, + border: BorderStroke? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + MaterialSurface( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + color = color, + contentColor = contentColor, + tonalElevation = elevation, + shadowElevation = elevation, + border = border, + interactionSource = interactionSource, + content = content, + ) + } +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Text.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Text.kt new file mode 100644 index 00000000..77568293 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/component/Text.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2022 Woolworths. All rights reserved. + */ + +package io.github.reactivecircus.kstreamlined.android.designsystem.component + +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor + +@Composable +public fun Text( + text: String, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val textColor = color.takeOrElse { + style.color.takeOrElse { + LocalContentColor.current + } + } + val mergedStyle = style.merge( + TextStyle( + color = textColor, + textAlign = textAlign ?: style.textAlign, + textDecoration = textDecoration, + ) + ) + BasicText( + text, + modifier, + mergedStyle, + onTextLayout, + overflow, + softWrap, + maxLines, + minLines, + ) +} + +@Composable +public fun Text( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val textColor = color.takeOrElse { + style.color.takeOrElse { + LocalContentColor.current + } + } + val mergedStyle = style.merge( + TextStyle( + color = textColor, + textAlign = textAlign ?: style.textAlign, + textDecoration = textDecoration, + ) + ) + BasicText( + text = text, + modifier = modifier, + style = mergedStyle, + onTextLayout = onTextLayout, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + ) +} + +public val LocalTextStyle: ProvidableCompositionLocal = + compositionLocalOf(structuralEqualityPolicy()) { TextStyle.Default } + +@Composable +public fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) { + val mergedStyle = LocalTextStyle.current.merge(value) + CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content) +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/KSTheme.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/KSTheme.kt new file mode 100644 index 00000000..43d70242 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/KSTheme.kt @@ -0,0 +1,47 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.DarkColorScheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.KSColorScheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LightColorScheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalContentColor +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color.LocalKSColorScheme +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.typography.KSTypography +import io.github.reactivecircus.kstreamlined.android.designsystem.foundation.typography.LocalKSTypography + +@Composable +public fun KSTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) { + DarkColorScheme + } else { + LightColorScheme + } + + CompositionLocalProvider( + LocalKSColorScheme provides colorScheme, + LocalContentColor provides colorScheme.onBackground, + LocalKSTypography provides KSTypography.Default, + ) { + MaterialTheme( + content = content, + ) + } +} + +public object KSTheme { + public val colorScheme: KSColorScheme + @Composable + @ReadOnlyComposable + get() = LocalKSColorScheme.current + public val typography: KSTypography + @Composable + @ReadOnlyComposable + get() = LocalKSTypography.current +} 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 new file mode 100644 index 00000000..9772a392 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/color/KSColorScheme.kt @@ -0,0 +1,89 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.color + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +public class KSColorScheme internal constructor( + public val primary: Color, + public val onPrimary: Color, + public val secondary: Color, + public val tertiary: 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, +) + +internal val LightColorScheme = KSColorScheme( + primary = Palette.CandyGrapeFizz50, + onPrimary = Palette.Lavender50, + secondary = Palette.PromiscuousPink50, + tertiary = Palette.Watermelonade, + 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, + ), + isDark = false, +) + +internal val DarkColorScheme = KSColorScheme( + primary = Palette.CandyGrapeFizz40, + onPrimary = Palette.Lavender40, + secondary = Palette.PromiscuousPink50, + background = Palette.MysteriousDepths10, + onBackground = Palette.Lavender50, + onBackgroundVariant = Palette.Lavender30, + tertiary = Palette.Watermelonade, + container = Palette.MysteriousDepths20, + primaryOnContainer = Palette.CandyGrapeFizz60, + outline = Palette.MysteriousDepths30, + gradient = listOf( + Palette.CandyGrapeFizz50, + Palette.PromiscuousPink50, + Palette.Watermelonade, + ), + isDark = true, +) + +private object Palette { + 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 Lavender50 = Color(0xFF_E7DBFF) + val Lavender40 = Color(0xFF_DBD0F2) + val Lavender30 = Color(0xFF_C4BAD9) + + val CandyDreams20 = Color(0xFF_F1C2F9) + val CandyDreams10 = Color(0xFF_EDB3F7) + + val MysteriousDepths30 = Color(0xFF_2C2F4E) + val MysteriousDepths20 = Color(0xFF_18193A) + val MysteriousDepths10 = Color(0xFF_070426) +} + +internal val LocalKSColorScheme = staticCompositionLocalOf { + error("No KSColorScheme provided") +} + +public val LocalContentColor: ProvidableCompositionLocal = + compositionLocalOf { error("No ContentColor provided") } diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkAdd.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkAdd.kt new file mode 100644 index 00000000..37110650 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkAdd.kt @@ -0,0 +1,51 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Copied from `androidx.compose.material.icons.outlined.BookmarkAdd`. + */ +public val KSIcons.BookmarkAdd: ImageVector + get() { + if (_bookmarkAdd != null) { + return _bookmarkAdd!! + } + _bookmarkAdd = materialIcon(name = "Outlined.BookmarkAdd") { + materialPath { + moveTo(17.0f, 11.0f) + verticalLineToRelative(6.97f) + lineToRelative(-5.0f, -2.14f) + lineToRelative(-5.0f, 2.14f) + verticalLineTo(5.0f) + horizontalLineToRelative(6.0f) + verticalLineTo(3.0f) + horizontalLineTo(7.0f) + curveTo(5.9f, 3.0f, 5.0f, 3.9f, 5.0f, 5.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(11.0f) + horizontalLineTo(17.0f) + close() + moveTo(21.0f, 7.0f) + horizontalLineToRelative(-2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + verticalLineTo(7.0f) + horizontalLineToRelative(-2.0f) + verticalLineTo(5.0f) + horizontalLineToRelative(2.0f) + verticalLineTo(3.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(2.0f) + verticalLineTo(7.0f) + close() + } + } + return _bookmarkAdd!! + } + +private var _bookmarkAdd: ImageVector? = null diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkFill.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkFill.kt new file mode 100644 index 00000000..a9f5c3ec --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/BookmarkFill.kt @@ -0,0 +1,31 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Copied from `androidx.compose.material.icons.rounded.Bookmark`. + */ +public val KSIcons.BookmarkFill: ImageVector + get() { + if (_bookmark != null) { + return _bookmark!! + } + _bookmark = materialIcon(name = "Rounded.Bookmark") { + materialPath { + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + } + } + return _bookmark!! + } + +private var _bookmark: ImageVector? = null 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 new file mode 100644 index 00000000..e1f6cece --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/KSIcons.kt @@ -0,0 +1,13 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon + +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.Settings +import androidx.compose.ui.graphics.vector.ImageVector + +public object KSIcons { + public val Close: ImageVector = Icons.Rounded.Close + public val Settings: ImageVector = Icons.Rounded.Settings + public val ArrowDown: ImageVector = Icons.Rounded.KeyboardArrowDown +} diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/Sync.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/Sync.kt new file mode 100644 index 00000000..0c1427ff --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/icon/Sync.kt @@ -0,0 +1,52 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.icon + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Copied from `androidx.compose.material.icons.rounded.Cached`. + */ +public val KSIcons.Sync: ImageVector + get() { + if (_cached != null) { + return _cached!! + } + _cached = materialIcon(name = "Rounded.Cached") { + materialPath { + moveTo(18.65f, 8.35f) + lineToRelative(-2.79f, 2.79f) + curveToRelative(-0.32f, 0.32f, -0.1f, 0.86f, 0.35f, 0.86f) + horizontalLineTo(18.0f) + curveToRelative(0.0f, 3.31f, -2.69f, 6.0f, -6.0f, 6.0f) + curveToRelative(-0.79f, 0.0f, -1.56f, -0.15f, -2.25f, -0.44f) + curveToRelative(-0.36f, -0.15f, -0.77f, -0.04f, -1.04f, 0.23f) + curveToRelative(-0.51f, 0.51f, -0.33f, 1.37f, 0.34f, 1.64f) + curveToRelative(0.91f, 0.37f, 1.91f, 0.57f, 2.95f, 0.57f) + curveToRelative(4.42f, 0.0f, 8.0f, -3.58f, 8.0f, -8.0f) + horizontalLineToRelative(1.79f) + curveToRelative(0.45f, 0.0f, 0.67f, -0.54f, 0.35f, -0.85f) + lineToRelative(-2.79f, -2.79f) + curveToRelative(-0.19f, -0.2f, -0.51f, -0.2f, -0.7f, -0.01f) + close() + moveTo(6.0f, 12.0f) + curveToRelative(0.0f, -3.31f, 2.69f, -6.0f, 6.0f, -6.0f) + curveToRelative(0.79f, 0.0f, 1.56f, 0.15f, 2.25f, 0.44f) + curveToRelative(0.36f, 0.15f, 0.77f, 0.04f, 1.04f, -0.23f) + curveToRelative(0.51f, -0.51f, 0.33f, -1.37f, -0.34f, -1.64f) + curveTo(14.04f, 4.2f, 13.04f, 4.0f, 12.0f, 4.0f) + curveToRelative(-4.42f, 0.0f, -8.0f, 3.58f, -8.0f, 8.0f) + horizontalLineTo(2.21f) + curveToRelative(-0.45f, 0.0f, -0.67f, 0.54f, -0.35f, 0.85f) + lineToRelative(2.79f, 2.79f) + curveToRelative(0.2f, 0.2f, 0.51f, 0.2f, 0.71f, 0.0f) + lineToRelative(2.79f, -2.79f) + curveToRelative(0.31f, -0.31f, 0.09f, -0.85f, -0.36f, -0.85f) + horizontalLineTo(6.0f) + close() + } + } + return _cached!! + } + +private var _cached: ImageVector? = null diff --git a/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/typography/KSTypography.kt b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/typography/KSTypography.kt new file mode 100644 index 00000000..48d7c355 --- /dev/null +++ b/android/designsystem/src/main/java/io/github/reactivecircus/kstreamlined/android/designsystem/foundation/typography/KSTypography.kt @@ -0,0 +1,169 @@ +package io.github.reactivecircus.kstreamlined.android.designsystem.foundation.typography + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import io.github.reactivecircus.kstreamlined.android.designsystem.R + +@Immutable +public class KSTypography internal constructor( + public val headlineLarge: TextStyle, + public val headlineMedium: TextStyle, + public val headlineSmall: TextStyle, + public val titleLarge: TextStyle, + public val titleMedium: TextStyle, + public val titleSmall: TextStyle, + public val bodyLarge: TextStyle, + public val bodyMedium: TextStyle, + public val bodySmall: TextStyle, + public val labelLarge: TextStyle, + public val labelMedium: TextStyle, + public val labelSmall: TextStyle, +) { + @Suppress("CyclomaticComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is KSTypography) return false + + if (headlineLarge != other.headlineLarge) return false + if (headlineMedium != other.headlineMedium) return false + if (headlineSmall != other.headlineSmall) return false + if (titleLarge != other.titleLarge) return false + if (titleMedium != other.titleMedium) return false + if (titleSmall != other.titleSmall) return false + if (bodyLarge != other.bodyLarge) return false + if (bodyMedium != other.bodyMedium) return false + if (bodySmall != other.bodySmall) return false + if (labelLarge != other.labelLarge) return false + if (labelMedium != other.labelMedium) return false + if (labelSmall != other.labelSmall) return false + + return true + } + + override fun hashCode(): Int { + var result = headlineLarge.hashCode() + result = 31 * result + headlineMedium.hashCode() + result = 31 * result + headlineSmall.hashCode() + result = 31 * result + titleLarge.hashCode() + result = 31 * result + titleMedium.hashCode() + result = 31 * result + titleSmall.hashCode() + result = 31 * result + bodyLarge.hashCode() + result = 31 * result + bodyMedium.hashCode() + result = 31 * result + bodySmall.hashCode() + result = 31 * result + labelLarge.hashCode() + result = 31 * result + labelMedium.hashCode() + result = 31 * result + labelSmall.hashCode() + return result + } + + @Suppress("MaxLineLength") + override fun toString(): String { + return "KSTypography(headlineLarge=$headlineLarge, headlineMedium=$headlineMedium, headlineSmall=$headlineSmall, " + + "titleLarge=$titleLarge, titleMedium=$titleMedium, titleSmall=$titleSmall, " + + "bodyLarge=$bodyLarge, bodyMedium=$bodyMedium, bodySmall=$bodySmall, " + + "labelLarge=$labelLarge, labelMedium=$labelMedium, labelSmall=$labelSmall)" + } + + internal companion object { + internal val Default: KSTypography = KSTypography( + headlineLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 16.sp, + letterSpacing = 0.sp, + ), + ) + } +} + +internal val LocalKSTypography = staticCompositionLocalOf { KSTypography.Default } + +private val JetBrainsMonoFontFamily = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_medium, FontWeight.Medium), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold), + Font(R.font.jetbrains_mono_extra_bold, FontWeight.ExtraBold), +) diff --git a/android/designsystem/src/main/res/font/jetbrains_mono_bold.ttf b/android/designsystem/src/main/res/font/jetbrains_mono_bold.ttf new file mode 100644 index 00000000..b7484374 Binary files /dev/null and b/android/designsystem/src/main/res/font/jetbrains_mono_bold.ttf differ diff --git a/android/designsystem/src/main/res/font/jetbrains_mono_extra_bold.ttf b/android/designsystem/src/main/res/font/jetbrains_mono_extra_bold.ttf new file mode 100644 index 00000000..88eab2f7 Binary files /dev/null and b/android/designsystem/src/main/res/font/jetbrains_mono_extra_bold.ttf differ diff --git a/android/designsystem/src/main/res/font/jetbrains_mono_medium.ttf b/android/designsystem/src/main/res/font/jetbrains_mono_medium.ttf new file mode 100644 index 00000000..ad31fbd7 Binary files /dev/null and b/android/designsystem/src/main/res/font/jetbrains_mono_medium.ttf differ diff --git a/android/designsystem/src/main/res/font/jetbrains_mono_regular.ttf b/android/designsystem/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 00000000..02bc07ea Binary files /dev/null and b/android/designsystem/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/android/feature/common/build.gradle.kts b/android/feature/common/build.gradle.kts index 831b539b..23050ab3 100644 --- a/android/feature/common/build.gradle.kts +++ b/android/feature/common/build.gradle.kts @@ -27,10 +27,8 @@ dependencies { api(libs.androidx.core) // Compose - api(libs.androidx.compose.material3) api(libs.androidx.compose.foundation) - debugApi(libs.androidx.compose.ui.tooling) - api(libs.androidx.compose.ui.toolingPreview) + api(libs.androidx.compose.ui.tooling) api(libs.androidx.lifecycle.runtime) api(libs.androidx.lifecycle.runtimeCompose) api(libs.androidx.navigation.compose) diff --git a/android/feature/home/build.gradle.kts b/android/feature/home/build.gradle.kts index 3ab60a64..8a1d172f 100644 --- a/android/feature/home/build.gradle.kts +++ b/android/feature/home/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { implementation(project(":feature:common")) + implementation(project(":common-ui:feed")) + implementation(project(":kmp:data")) } 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 new file mode 100644 index 00000000..2fe920ca --- /dev/null +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/HomeScreen.kt @@ -0,0 +1,249 @@ +package io.github.reactivecircus.kstreamlined.android.feature.home + +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.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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import io.github.reactivecircus.kstreamlined.android.common.ui.feed.KotlinWeeklyCard +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.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 + +@Composable +public fun HomeScreen( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(KSTheme.colorScheme.background), + ) { + Surface( + elevation = 2.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) + ) + + Spacer(modifier = Modifier.weight(1f)) + + FilledIconButton( + KSIcons.Settings, + contentDescription = null, + onClick = {}, + iconTint = KSTheme.colorScheme.primaryOnContainer, + ) + + Spacer(modifier = Modifier.width(16.dp)) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 24.dp), + ) { + FeedFilterChip(selectedFeedCount = 4) + + var syncing by remember { mutableStateOf(false) } + + SyncButton( + onClick = { syncing = true }, + syncing = syncing, + ) + + LaunchedEffect(syncing) { + if (syncing) { + @Suppress("MagicNumber") + (delay(500)) + syncing = false + } + } + } + } + } + + LazyColumn( + contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Text( + text = "Today", + 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", + savedForLater = false, + ) + ) + } + KotlinWeeklyCard( + item = item, + onItemClick = {}, + onSaveButtonClick = { + item = item.copy(savedForLater = !item.savedForLater) + }, + ) + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.container, + ) {} + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.container, + ) {} + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.container, + ) {} + } + + item { + Text( + text = "This week", + style = KSTheme.typography.titleMedium, + color = KSTheme.colorScheme.onBackgroundVariant, + ) + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.onBackgroundVariant, + ) {} + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.primary, + ) {} + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.secondary, + ) {} + } + + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(16.dp), + color = KSTheme.colorScheme.tertiary, + ) {} + } + } + } +} + +@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 new file mode 100644 index 00000000..aa5e16e0 --- /dev/null +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/FeedFilterChip.kt @@ -0,0 +1,49 @@ +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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 + +@Composable +internal fun FeedFilterChip( + selectedFeedCount: Int, + modifier: Modifier = Modifier, +) { + Surface( + onClick = {}, + modifier = modifier, + shape = CircleShape, + color = KSTheme.colorScheme.container, + contentColor = KSTheme.colorScheme.primaryOnContainer, + ) { + Row( + modifier = Modifier.padding( + vertical = 8.dp, + horizontal = 12.dp, + ), + 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) + } + } +} 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 new file mode 100644 index 00000000..23941d65 --- /dev/null +++ b/android/feature/home/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/home/component/SyncButton.kt @@ -0,0 +1,95 @@ +package io.github.reactivecircus.kstreamlined.android.feature.home.component + +import androidx.compose.animation.core.Animatable +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.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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 + +@Composable +internal fun SyncButton( + onClick: () -> Unit, + syncing: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier, + enabled = !syncing, + shape = CircleShape, + color = KSTheme.colorScheme.container, + contentColor = KSTheme.colorScheme.primaryOnContainer, + ) { + 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) { + rotation.animateTo( + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(AnimationDurationMillis, easing = LinearEasing), + ), + ) { + currentRotation = value + } + } else { + if (currentRotation > 0f) { + rotation.animateTo( + targetValue = 360f, + animationSpec = tween( + durationMillis = AnimationDurationMillis, + easing = LinearOutSlowInEasing, + ), + ) { + 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, + ), + ) + } + } +} + +private const val AnimationDurationMillis = 1000 diff --git a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt index f146b597..6ef46beb 100644 --- a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt +++ b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt @@ -4,7 +4,6 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinCommonCompile import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** * Apply common configs to KMP project. diff --git a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/SlimTests.kt b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/SlimTests.kt index 031b474f..9c7251c6 100644 --- a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/SlimTests.kt +++ b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/SlimTests.kt @@ -18,7 +18,7 @@ import org.gradle.kotlin.dsl.findByType * in Android App and Library projects, and all tests in JVM projects and Kotlin Multiplatform projects. */ internal fun Project.configureSlimTests() { - if (providers.gradleProperty(SLIM_TESTS_PROPERTY).isPresent) { + if (providers.gradleProperty(SlimTestsProperty).isPresent) { // disable unit test tasks on the release, benchmark build types for Android Library projects extensions.findByType()?.run { beforeVariants(selector().withBuildType("release")) { @@ -50,4 +50,4 @@ internal fun Project.configureSlimTests() { } } -private const val SLIM_TESTS_PROPERTY = "slimTests" +private const val SlimTestsProperty = "slimTests" diff --git a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/convention/KMPCommonConventionPlugin.kt b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/convention/KMPCommonConventionPlugin.kt index 4f0497a5..c6234b58 100644 --- a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/convention/KMPCommonConventionPlugin.kt +++ b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/convention/KMPCommonConventionPlugin.kt @@ -1,10 +1,10 @@ package io.github.reactivecircus.kstreamlined.buildlogic.convention import io.github.reactivecircus.kstreamlined.buildlogic.configureDetekt -import io.github.reactivecircus.kstreamlined.buildlogic.enableExplicitApi import io.github.reactivecircus.kstreamlined.buildlogic.configureKMPCommon import io.github.reactivecircus.kstreamlined.buildlogic.configureKotlinCommonCompileOptions import io.github.reactivecircus.kstreamlined.buildlogic.configureTest +import io.github.reactivecircus.kstreamlined.buildlogic.enableExplicitApi import io.github.reactivecircus.kstreamlined.buildlogic.markNonCompatibleConfigurationCacheTasks import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 565b4b10..33338da6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,7 +84,6 @@ firebase-remoteConfig = { module = "com.google.firebase:firebase-config-ktx", ve firebase-perf = { module = "com.google.firebase:firebase-perf-ktx", version.ref = "firebase-perf" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx", version.ref = "firebase-crashlytics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" } -androidx-compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" } androidx-compose-ui-testJunit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose-ui" } androidx-compose-ui-testManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-ui" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose-foundation" } diff --git a/kmp/data/build.gradle.kts b/kmp/data/build.gradle.kts index d6dc2664..46ddec1d 100644 --- a/kmp/data/build.gradle.kts +++ b/kmp/data/build.gradle.kts @@ -9,6 +9,7 @@ kotlin { dependencies { implementation(project(":kmp:feed-datasource:common")) implementation(project(":kmp:persistence")) + api(project(":kmp:model")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kermit) } diff --git a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedRepository.kt b/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedRepository.kt index 560e14ee..6f2eb28d 100644 --- a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedRepository.kt +++ b/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedRepository.kt @@ -1,9 +1,9 @@ package io.github.reactivecircus.kstreamlined.kmp.data.feed import co.touchlab.kermit.Logger -import io.github.reactivecircus.kstreamlined.kmp.data.feed.model.FeedItem -import io.github.reactivecircus.kstreamlined.kmp.data.feed.model.FeedOrigin import io.github.reactivecircus.kstreamlined.kmp.feed.datasource.FeedDataSource +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow diff --git a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedSyncState.kt b/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedSyncState.kt index a82a868f..9f69d7fe 100644 --- a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedSyncState.kt +++ b/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/FeedSyncState.kt @@ -1,7 +1,7 @@ package io.github.reactivecircus.kstreamlined.kmp.data.feed -import io.github.reactivecircus.kstreamlined.kmp.data.feed.model.FeedItem -import io.github.reactivecircus.kstreamlined.kmp.data.feed.model.FeedOrigin +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedItem +import io.github.reactivecircus.kstreamlined.kmp.model.feed.FeedOrigin public data class FeedSyncState( val feedOrigins: List, diff --git a/kmp/model/build.gradle.kts b/kmp/model/build.gradle.kts new file mode 100644 index 00000000..693f10c3 --- /dev/null +++ b/kmp/model/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("kstreamlined.kmp.common") +} diff --git a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedItem.kt b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt similarity index 95% rename from kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedItem.kt rename to kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt index e4fd9042..80304bcc 100644 --- a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedItem.kt +++ b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedItem.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.kmp.data.feed.model +package io.github.reactivecircus.kstreamlined.kmp.model.feed public sealed interface FeedItem { public val id: String diff --git a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedOrigin.kt b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedOrigin.kt similarity index 80% rename from kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedOrigin.kt rename to kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedOrigin.kt index 5b0d1202..1daab877 100644 --- a/kmp/data/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/data/feed/model/FeedOrigin.kt +++ b/kmp/model/src/commonMain/kotlin/io/github/reactivecircus/kstreamlined/kmp/model/feed/FeedOrigin.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.kmp.data.feed.model +package io.github.reactivecircus.kstreamlined.kmp.model.feed public data class FeedOrigin( val key: Key, diff --git a/settings.gradle.kts b/settings.gradle.kts index a8f3a9a4..c5bf6a4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include(":kmp:feed-datasource:cloud") include(":kmp:feed-datasource:edge") include(":kmp:feed-datasource:testing") include(":kmp:persistence") +include(":kmp:model") include(":kmp:core-utils") include(":kmp:test-utils") @@ -74,6 +75,7 @@ if (!isXCFrameworkBuild) { includeProject(":app", "android/app") includeProject(":feature:common", "android/feature/common") includeProject(":feature:home", "android/feature/home") + includeProject(":common-ui:feed", "android/common-ui/feed") includeProject(":designsystem", "android/designsystem") }