diff --git a/build-plugins/src/main/kotlin/io.ashdavies.android.gradle.kts b/build-plugins/src/main/kotlin/io.ashdavies.android.gradle.kts index 745c86816..6355fe8fc 100644 --- a/build-plugins/src/main/kotlin/io.ashdavies.android.gradle.kts +++ b/build-plugins/src/main/kotlin/io.ashdavies.android.gradle.kts @@ -1,9 +1,16 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { kotlin("multiplatform") } kotlin { - androidTarget() + androidTarget { + val jvmTarget = JvmTarget.fromTarget(libs.versions.kotlin.jvmTarget.get()) + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions.jvmTarget.set(jvmTarget) + } } pluginManager.commonExtension { diff --git a/build-plugins/src/main/kotlin/io.ashdavies.kotlin.gradle.kts b/build-plugins/src/main/kotlin/io.ashdavies.kotlin.gradle.kts index 2495da51e..3b603d4f0 100644 --- a/build-plugins/src/main/kotlin/io.ashdavies.kotlin.gradle.kts +++ b/build-plugins/src/main/kotlin/io.ashdavies.kotlin.gradle.kts @@ -1,6 +1,7 @@ import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.DetektPlugin import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jlleitschuh.gradle.ktlint.KtlintExtension @@ -38,11 +39,10 @@ extensions.configure { tasks.withType { - val jvmTargetVersion = libs.versions.kotlin.jvmTarget.get() compilerOptions { freeCompilerArgs.addAll("-Xexpect-actual-classes") - jvmTarget.set(JvmTarget.fromTarget(jvmTargetVersion)) + jvmTarget.set(JvmTarget.fromTarget(libs.versions.kotlin.jvmTarget.get())) } } diff --git a/conferences-app/build.gradle.kts b/conferences-app/build.gradle.kts index 66a05d543..a993abff5 100644 --- a/conferences-app/build.gradle.kts +++ b/conferences-app/build.gradle.kts @@ -117,6 +117,7 @@ kotlin { implementation(projects.sqlCompose) implementation(projects.sqlDriver) + implementation(compose.components.resources) implementation(compose.foundation) implementation(compose.material) implementation(compose.material3) @@ -127,6 +128,7 @@ kotlin { implementation(libs.androidx.paging.common) implementation(libs.coil.compose) implementation(libs.coil.network) + implementation(libs.compose.window.size) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.core) diff --git a/conferences-app/src/androidMain/kotlin/io/ashdavies/party/MainActivity.kt b/conferences-app/src/androidMain/kotlin/io/ashdavies/party/MainActivity.kt index 2746daec0..997f1b913 100644 --- a/conferences-app/src/androidMain/kotlin/io/ashdavies/party/MainActivity.kt +++ b/conferences-app/src/androidMain/kotlin/io/ashdavies/party/MainActivity.kt @@ -1,5 +1,6 @@ package io.ashdavies.party +import android.app.Activity import android.content.Context import android.os.Build import android.os.Bundle @@ -7,8 +8,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.core.content.pm.PackageInfoCompat import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals @@ -24,6 +26,7 @@ import io.ashdavies.io.resolveCacheDir import io.ashdavies.material.dynamicColorScheme import io.ashdavies.party.config.rememberCircuit import io.ashdavies.party.home.HomeScreen +import io.ashdavies.party.material.ProvideLocalWindowSizeClass import io.ashdavies.playground.BuildConfig import io.ashdavies.playground.PlaygroundDatabase import io.ashdavies.sql.ProvideTransacter @@ -44,43 +47,46 @@ internal class MainActivity : ComponentActivity() { } setContent { - ConferencesApp() + ConferencesApp(this) } } } @Composable -private fun ConferencesApp(context: Context = LocalContext.current) { +private fun ConferencesApp(activity: Activity) { ProvideHttpClient( config = { install(DefaultRequest) { - header("X-Android-Cert", context.getFirstSignatureOrNull()) - header("X-Android-Package", context.packageName) + header("X-Android-Cert", activity.getFirstSignatureOrNull()) + header("X-Android-Package", activity.packageName) header("X-API-Key", BuildConfig.ANDROID_API_KEY) header("User-Agent", Build.PRODUCT) } install(HttpCache) { - publicStorage(context.resolveCacheDir()) + publicStorage(activity.resolveCacheDir()) } }, ) { ProvideAppCheckToken { val transacter = rememberTransacter( schema = PlaygroundDatabase.Schema, - context = context, + context = activity, ) { PlaygroundDatabase(it) } ProvideTransacter(transacter) { MaterialTheme(dynamicColorScheme()) { - CircuitCompositionLocals(rememberCircuit(context)) { + CircuitCompositionLocals(rememberCircuit(activity)) { ContentWithOverlays { - val backStack = rememberSaveableBackStack(HomeScreen) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + ProvideLocalWindowSizeClass(calculateWindowSizeClass(activity)) { + val backStack = rememberSaveableBackStack(HomeScreen) - NavigableCircuitContent( - navigator = rememberCircuitNavigator(backStack), - backStack = backStack, - ) + NavigableCircuitContent( + navigator = rememberCircuitNavigator(backStack), + backStack = backStack, + ) + } } } } diff --git a/conferences-app/src/androidMain/res/values/strings.xml b/conferences-app/src/androidMain/res/values/strings.xml deleted file mode 100644 index 86d2eb930..000000000 --- a/conferences-app/src/androidMain/res/values/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - Playground - Upcoming - Profile - Events - diff --git a/conferences-app/src/commonMain/composeResources/values/strings.xml b/conferences-app/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..6f0976360 --- /dev/null +++ b/conferences-app/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,23 @@ + + + Playground + Upcoming + Profile + Events + + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + + Call for Papers (Until %1$s) + Online Only + diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsScreen.kt index 5c726e535..b38d3d345 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsScreen.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsScreen.kt @@ -1,6 +1,5 @@ package io.ashdavies.party.events -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -10,7 +9,9 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -20,10 +21,15 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +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.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush @@ -42,13 +48,19 @@ import io.ashdavies.analytics.OnClick import io.ashdavies.paging.LazyPagingItems import io.ashdavies.parcelable.Parcelable import io.ashdavies.parcelable.Parcelize +import io.ashdavies.party.material.LocalWindowSizeClass import io.ashdavies.placeholder.PlaceholderHighlight import io.ashdavies.placeholder.fade import io.ashdavies.placeholder.placeholder import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import playground.conferences_app.generated.resources.Res +import playground.conferences_app.generated.resources.call_for_papers +import playground.conferences_app.generated.resources.online_only private const val EMPTY_STRING = "" @@ -62,10 +74,7 @@ internal object EventsScreen : Parcelable, Screen { } @Composable -@OptIn( - ExperimentalFoundationApi::class, - ExperimentalMaterialApi::class, -) +@OptIn(ExperimentalMaterialApi::class) internal fun EventsScreen( state: EventsScreen.State, modifier: Modifier = Modifier, @@ -97,7 +106,7 @@ internal fun EventsScreen( 1 -> TextEmphasis.Moderate else -> TextEmphasis.Standard }, - modifier = Modifier.animateItemPlacement(), + modifier = Modifier.animateItem(), event = state.pagingItems.getOrNull(index), ) } @@ -124,9 +133,10 @@ private enum class TextEmphasis { @Composable @ExperimentalMaterialApi private fun EventSection( + event: Event?, emphasis: TextEmphasis, modifier: Modifier = Modifier, - event: Event? = null, + windowClassSize: WindowSizeClass = LocalWindowSizeClass.current, ) { Card( modifier = modifier @@ -144,74 +154,118 @@ private fun EventSection( ) } - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 8.dp, - ), - ) { - PlaceholderText( - text = event?.name, - modifier = Modifier.align(Alignment.Start), - style = when (emphasis) { - TextEmphasis.Significant -> MaterialTheme.typography.headlineLarge - TextEmphasis.Moderate -> MaterialTheme.typography.headlineMedium - TextEmphasis.Standard -> MaterialTheme.typography.headlineSmall - }, - ) + Row { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + Row { + PlaceholderText( + text = event?.name, + style = when (emphasis) { + TextEmphasis.Significant -> MaterialTheme.typography.headlineLarge + TextEmphasis.Moderate -> MaterialTheme.typography.headlineMedium + TextEmphasis.Standard -> MaterialTheme.typography.headlineSmall + }, + ) - PlaceholderText( - text = event?.location, - modifier = Modifier.align(Alignment.Start), - style = when (emphasis) { - TextEmphasis.Significant -> MaterialTheme.typography.titleLarge - TextEmphasis.Moderate -> MaterialTheme.typography.titleMedium - TextEmphasis.Standard -> MaterialTheme.typography.titleSmall - }, - ) + if (windowClassSize.widthSizeClass == WindowWidthSizeClass.Expanded) { + EventStatusChips( + cfpEnd = event?.cfpEnd, + isOnlineOnly = event?.online == true, + modifier = Modifier.padding(start = 12.dp), + ) + } + } - PlaceholderText( - text = event?.dateStart, - modifier = Modifier.align(Alignment.Start), - style = when (emphasis) { - TextEmphasis.Significant -> MaterialTheme.typography.labelLarge - TextEmphasis.Moderate -> MaterialTheme.typography.labelMedium - TextEmphasis.Standard -> MaterialTheme.typography.labelSmall - }, + PlaceholderText( + text = event?.location, + modifier = Modifier.align(Alignment.Start), + style = when (emphasis) { + TextEmphasis.Significant -> MaterialTheme.typography.titleLarge + TextEmphasis.Moderate -> MaterialTheme.typography.titleMedium + TextEmphasis.Standard -> MaterialTheme.typography.titleSmall + }, + ) + + if (windowClassSize.widthSizeClass == WindowWidthSizeClass.Compact) { + EventStatusChips( + cfpEnd = event?.cfpEnd, + isOnlineOnly = event?.online == true, + ) + } + } + + if (event?.dateStart != null) { + EventDateLabel( + dateStart = remember { LocalDate.parse(event.dateStart) }, + modifier = Modifier.padding(12.dp), + ) + } + } + } + } +} + +@Composable +private fun EventDateLabel( + dateStart: LocalDate, + modifier: Modifier = Modifier, +) { + Surface(modifier.clip(MaterialTheme.shapes.small)) { + Box( + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 4.dp, + ), + ) { + Column { + Text( + text = dateStart.format(LocalDate.Format { monthName() }), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.labelSmall, ) - EventStatusRow( - cfpEnd = event?.cfpEnd, - isOnlineOnly = event?.online == true, + Text( + text = dateStart.format(LocalDate.Format { dayOfMonth() }), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.labelLarge, ) + + val currentYear = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .year + + if (dateStart.year != currentYear) { + Text( + text = dateStart.format(LocalDate.Format { year() }), + style = MaterialTheme.typography.labelSmall, + ) + } } } } } @Composable -private fun EventStatusRow( +private fun EventStatusChips( cfpEnd: String?, isOnlineOnly: Boolean, modifier: Modifier = Modifier, ) { Row(modifier) { if (cfpEnd != null && LocalDate.parse(cfpEnd) > Today) { - SuggestionChip( - text = "Call for Papers (Until $cfpEnd)", - onClick = OnClick("event_cfp") { }, - modifier = Modifier.padding(end = 8.dp), - ) + SuggestionChip(stringResource(Res.string.call_for_papers, cfpEnd)) + Divider(modifier.width(8.dp)) } if (isOnlineOnly) { - SuggestionChip( - text = "Online Only", - onClick = { }, - enabled = false, - ) + SuggestionChip(stringResource(Res.string.online_only)) } } } @@ -244,20 +298,19 @@ private fun EventSectionBackground( @Composable private fun SuggestionChip( text: String, - onClick: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true, ) { SuggestionChip( - onClick = onClick, + onClick = { }, label = { Text( text = text, color = LocalContentColor.current, + style = MaterialTheme.typography.labelSmall, ) }, modifier = modifier, - enabled = enabled, + enabled = false, shape = MaterialTheme.shapes.small, ) } @@ -267,7 +320,7 @@ internal fun PlaceholderText( text: String?, modifier: Modifier = Modifier, verticalPadding: Dp = 2.dp, - characters: Int = 12, + minWidth: Dp = 64.dp, style: TextStyle = LocalTextStyle.current, ) { Text( @@ -277,7 +330,7 @@ internal fun PlaceholderText( maxLines = 1, modifier = modifier .padding(vertical = verticalPadding) - .defaultMinSize(minWidth = Dp(style.fontSize.value * characters)) + .defaultMinSize(minWidth = minWidth) .placeholder(text == null, highlight = PlaceholderHighlight.fade()), ) } diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/MonthNames.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/MonthNames.kt new file mode 100644 index 000000000..2e5ec1dbe --- /dev/null +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/MonthNames.kt @@ -0,0 +1,23 @@ +package io.ashdavies.party.events + +import kotlinx.datetime.format.DateTimeFormatBuilder +import kotlinx.datetime.format.MonthNames + +private val MonthNamesList = listOf( + "Jan", // Res.string.january, + "Feb", // Res.string.february, + "Mar", // Res.string.march, + "Apr", // Res.string.april, + "May", // Res.string.may, + "Jun", // Res.string.june, + "Jul", // Res.string.july, + "Aug", // Res.string.august, + "Sep", // Res.string.september, + "Oct", // Res.string.october, + "Nov", // Res.string.november, + "Dec", // Res.string.december, +) + +internal fun DateTimeFormatBuilder.WithDate.monthName() { + monthName(MonthNames(MonthNamesList)) +} diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/WindowSizeClass.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/WindowSizeClass.kt new file mode 100644 index 000000000..c9ab83da4 --- /dev/null +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/WindowSizeClass.kt @@ -0,0 +1,21 @@ +package io.ashdavies.party.material + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalWindowSizeClass = staticCompositionLocalOf { + error("WindowSizeClass not provided") +} + +@Composable +internal fun ProvideLocalWindowSizeClass( + windowSizeClass: WindowSizeClass, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalWindowSizeClass provides windowSizeClass, + content = content, + ) +} diff --git a/conferences-app/src/jvmMain/kotlin/io/ashdavies/party/Main.kt b/conferences-app/src/jvmMain/kotlin/io/ashdavies/party/Main.kt index 6f1efee08..d57aa3154 100644 --- a/conferences-app/src/jvmMain/kotlin/io/ashdavies/party/Main.kt +++ b/conferences-app/src/jvmMain/kotlin/io/ashdavies/party/Main.kt @@ -1,6 +1,8 @@ package io.ashdavies.party import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.window.Window @@ -24,6 +26,7 @@ import io.ashdavies.material.dynamicColorScheme import io.ashdavies.party.config.rememberCircuit import io.ashdavies.party.firebase.EmptyLocalConfigValue import io.ashdavies.party.home.HomeScreen +import io.ashdavies.party.material.ProvideLocalWindowSizeClass import io.ashdavies.playground.BuildConfig import io.ashdavies.playground.KeyNavigationDecoration import io.ashdavies.playground.PlaygroundDatabase @@ -77,16 +80,19 @@ private fun ConferencesApp( CircuitCompositionLocals(circuit) { ContentWithOverlays { - val backStack = rememberSaveableBackStack(HomeScreen) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + ProvideLocalWindowSizeClass(calculateWindowSizeClass()) { + val backStack = rememberSaveableBackStack(HomeScreen) - NavigableCircuitContent( - navigator = rememberCircuitNavigator(backStack) { onClose() }, - backStack = backStack, - decoration = KeyNavigationDecoration( - decoration = circuit.defaultNavDecoration, - onBackInvoked = backStack::pop, - ), - ) + NavigableCircuitContent( + navigator = rememberCircuitNavigator(backStack) { onClose() }, + backStack = backStack, + decoration = KeyNavigationDecoration( + decoration = circuit.defaultNavDecoration, + onBackInvoked = backStack::pop, + ), + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b1e582728..3c4e6106d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = " compose-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-adaptive" } compose-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "compose-adaptive" } +compose-window-size = { module = "org.jetbrains.compose.material3:material3-window-size-class", version.ref = "compose-plugin" } detekt-compose = "io.nlopez.compose.rules:detekt:0.4.16"