diff --git a/conferences-app/src/commonMain/composeResources/values/strings.xml b/conferences-app/src/commonMain/composeResources/values/strings.xml index 8ac951a8d..c376395d9 100644 --- a/conferences-app/src/commonMain/composeResources/values/strings.xml +++ b/conferences-app/src/commonMain/composeResources/values/strings.xml @@ -4,7 +4,8 @@ Upcoming Events Past Events - Call for Papers (%1$s days left) + CfP Open + Call for Papers (%1$s days remaining) Call for Papers (Closed) Online Only diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventCfp.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventCfp.kt new file mode 100644 index 000000000..405bef7dd --- /dev/null +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventCfp.kt @@ -0,0 +1,15 @@ +package io.ashdavies.party.events + +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil +import kotlinx.datetime.toLocalDateTime + +private val Today = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + +internal fun daysUntilCfpEnd(cfpEnd: LocalDate): Int { + return Today.daysUntil(cfpEnd) +} diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventDateLabel.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventDateLabel.kt index 72bbc5697..c64256ea8 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventDateLabel.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventDateLabel.kt @@ -1,5 +1,6 @@ package io.ashdavies.party.events +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme @@ -31,6 +32,7 @@ internal fun EventDateLabel( ) { Column( modifier = Modifier.padding(MaterialTheme.spacing.small), + verticalArrangement = Arrangement.aligned(Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { val startMonth = dateStart.format(LocalDate.Format { monthName(MonthNames.ENGLISH_ABBREVIATED) }) diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsDetailPane.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsDetailPane.kt index 8a73f4889..5c0b24971 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsDetailPane.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsDetailPane.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material3.Card @@ -16,22 +17,32 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import io.ashdavies.party.material.LocalWindowSizeClass import io.ashdavies.party.material.padding import io.ashdavies.party.material.spacing import kotlinx.datetime.LocalDate import okio.ByteString.Companion.encode +import org.jetbrains.compose.resources.stringResource +import playground.conferences_app.generated.resources.Res +import playground.conferences_app.generated.resources.call_for_papers_closed +import playground.conferences_app.generated.resources.call_for_papers_days_remaining @Composable internal fun EventsDetailPane( event: Event, + onBackClick: () -> Unit, modifier: Modifier = Modifier, + windowSizeClass: WindowSizeClass = LocalWindowSizeClass.current, ) { Scaffold( modifier = modifier, @@ -43,6 +54,16 @@ internal fun EventsDetailPane( Icon(Icons.Default.Warning, contentDescription = null) } }, + navigationIcon = { + if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + } + }, ) }, ) { contentPadding -> @@ -64,13 +85,14 @@ internal fun EventsDetailPane( } } - Card( - modifier = Modifier - .padding(MaterialTheme.spacing.large) - .fillMaxWidth(), - ) { - EventsDetailLocation( - location = event.location, + EventsDetailLocation( + location = event.location, + ) + + if (event.cfpEnd != null) { + EventsDetailCfp( + cfpEnd = event.cfpEnd, + cfpSite = event.cfpSite, ) } } @@ -99,18 +121,55 @@ private fun EventsDetailLocation( location: String, modifier: Modifier = Modifier, ) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, + Card( + modifier = modifier + .padding(MaterialTheme.spacing.large) + .fillMaxWidth(), ) { - Icon( - imageVector = Icons.Outlined.MyLocation, - contentDescription = null, - modifier = Modifier.padding(16.dp), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.MyLocation, + contentDescription = null, + modifier = Modifier.padding(16.dp), + ) + + Column { + Text(location) + } + } + } +} + +@Composable +private fun EventsDetailCfp( + cfpSite: String?, + cfpEnd: String, + modifier: Modifier = Modifier, +) { + val daysUntilCfpEnd = daysUntilCfpEnd(LocalDate.parse(cfpEnd)) + val uriHandler = LocalUriHandler.current + val newModifier = modifier + .padding(MaterialTheme.spacing.large) + .fillMaxWidth() - Column { - Text(location) + when { + daysUntilCfpEnd > 0 && cfpSite != null -> Card( + onClick = { uriHandler.openUri(cfpSite) }, + modifier = newModifier, + ) { + Text( + text = stringResource(Res.string.call_for_papers_days_remaining, daysUntilCfpEnd), + modifier = Modifier.padding(16.dp), + ) + } + + else -> Card(newModifier) { + Text( + text = stringResource(Res.string.call_for_papers_closed), + modifier = Modifier.padding(16.dp), + ) } } } diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsTopBar.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsTopBar.kt index 5138a6a90..171e06412 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsTopBar.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/events/EventsTopBar.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier internal fun EventsTopBar( title: String, modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = { }, actions: @Composable RowScope.() -> Unit = { }, ) { CenterAlignedTopAppBar( @@ -25,6 +26,7 @@ internal fun EventsTopBar( ) }, modifier = modifier, + navigationIcon = navigationIcon, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background, ), diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsPane.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsPane.kt index 2c6b31d3b..f6ddabdeb 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsPane.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsPane.kt @@ -1,12 +1,13 @@ package io.ashdavies.party.upcoming +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -20,7 +21,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -28,23 +29,22 @@ 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.draw.paint import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter import io.ashdavies.analytics.OnClick import io.ashdavies.party.events.Event import io.ashdavies.party.events.EventDateLabel import io.ashdavies.party.events.EventsTopBar +import io.ashdavies.party.events.daysUntilCfpEnd import io.ashdavies.party.events.paging.errorMessage import io.ashdavies.party.events.paging.isRefreshing import io.ashdavies.party.material.padding @@ -53,24 +53,15 @@ import io.ashdavies.party.paging.items 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.daysUntil -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_closed import playground.conferences_app.generated.resources.call_for_papers_open import playground.conferences_app.generated.resources.online_only import playground.conferences_app.generated.resources.upcoming_events private const val EMPTY_STRING = "" -private val Today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun UpcomingEventsPane( @@ -108,7 +99,8 @@ internal fun UpcomingEventsPane( enabled = event != null, onClickLabel = event?.name, onClick = { onClick(event!!) }, - ), + ) + .paint(rememberBackgroundPainter(event?.imageUrl)), ) } } @@ -131,135 +123,101 @@ private fun EventItemContent( }, ), ) { - Box(Modifier.height(IntrinsicSize.Min)) { - if (event?.imageUrl != null) { - EventSectionBackground( - backgroundImageUrl = event.imageUrl, - modifier = Modifier.fillMaxSize(), + Row( + modifier = Modifier + .padding(MaterialTheme.spacing.large) + .height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small.horizontal), + ) { + Column(Modifier.weight(1f)) { + PlaceholderText( + text = event?.name, + style = MaterialTheme.typography.headlineSmall, ) - } - Column( - modifier = Modifier.padding(MaterialTheme.spacing.large), - ) { - Row { - Column( - modifier = Modifier.weight(1f), - ) { - PlaceholderText( - text = event?.name, - style = MaterialTheme.typography.headlineSmall, - ) - - PlaceholderText( - text = event?.location, - modifier = Modifier.align(Alignment.Start), - style = MaterialTheme.typography.titleSmall, - ) - } + PlaceholderText( + text = event?.location, + modifier = Modifier.align(Alignment.Start), + style = MaterialTheme.typography.titleSmall, + ) + } - if (event?.dateStart != null) { - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.large.horizontal), - ) + if (event?.cfpEnd != null && daysUntilCfpEnd(LocalDate.parse(event.cfpEnd)) > 0) { + Column { + EventLabel( + text = stringResource(Res.string.call_for_papers_open), + modifier = Modifier.fillMaxHeight(), + ) + } + } - EventDateLabel( - dateStart = remember { LocalDate.parse(event.dateStart) }, - dateEnd = remember { LocalDate.parse(event.dateEnd) }, - ) - } + if (event?.online != false) { + Column { + EventLabel( + text = stringResource(Res.string.online_only), + modifier = Modifier.fillMaxHeight(), + ) } + } - EventStatusChips( - cfpSite = event?.cfpSite, - cfpEnd = event?.cfpEnd, - isOnlineOnly = event?.online == true, - ) + if (event?.dateStart != null) { + Column { + EventDateLabel( + dateStart = remember { LocalDate.parse(event.dateStart) }, + dateEnd = remember { LocalDate.parse(event.dateEnd) }, + modifier = Modifier.fillMaxHeight(), + ) + } } } } } @Composable -private fun EventStatusChips( - cfpSite: String?, - cfpEnd: String?, - isOnlineOnly: Boolean, +private fun EventLabel( + text: String, modifier: Modifier = Modifier, ) { - Column(modifier) { - if (cfpEnd != null && isOnlineOnly) { - Spacer(Modifier.height(MaterialTheme.spacing.large.vertical)) - } - - Row { - if (cfpEnd != null) { - val daysUntilCfpEnd = Today.daysUntil(LocalDate.parse(cfpEnd)) - val uriHandler = LocalUriHandler.current - - SuggestionChip( - onClick = { uriHandler.openUri(requireNotNull(cfpSite)) }, - label = { - Text( - text = when { - daysUntilCfpEnd > 0 -> stringResource( - Res.string.call_for_papers_open, - daysUntilCfpEnd, - ) - - else -> stringResource(Res.string.call_for_papers_closed) - }, - color = LocalContentColor.current, - style = MaterialTheme.typography.labelSmall, - ) - }, - enabled = cfpSite != null && daysUntilCfpEnd > 0, - shape = MaterialTheme.shapes.small, - ) - } - - if (cfpEnd != null && isOnlineOnly) { - Spacer(Modifier.width(MaterialTheme.spacing.large.horizontal)) - } - - if (isOnlineOnly) { - SuggestionChip( - onClick = { }, - label = { - Text( - text = stringResource(Res.string.online_only), - color = LocalContentColor.current, - style = MaterialTheme.typography.labelSmall, - ) - }, - shape = MaterialTheme.shapes.small, - ) - } + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = Color.Transparent, + border = BorderStroke( + width = 1.0.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = text, + modifier = Modifier + .padding(MaterialTheme.spacing.small) + .width(32.dp), + color = LocalContentColor.current, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + ) } } } @Composable -private fun EventSectionBackground( - backgroundImageUrl: String, - modifier: Modifier = Modifier, +private fun rememberBackgroundPainter( + backgroundImageUrl: String?, colorStopStart: Float = 0.25f, colorStopEnd: Float = 0.5f, -) { - val gradientBrush = Brush.horizontalGradient( +): Painter { + @Suppress("UNUSED_VARIABLE") + val brush = Brush.horizontalGradient( colorStopStart to Color.Transparent, colorStopEnd to Color.Black, ) - AsyncImage( + return rememberAsyncImagePainter( model = backgroundImageUrl, - contentDescription = null, - modifier = modifier - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - drawRect(gradientBrush, blendMode = BlendMode.DstIn) - }, contentScale = ContentScale.Crop, ) } diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsScreen.kt index d0f84f4c0..3d24a3eb9 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsScreen.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/upcoming/UpcomingEventsScreen.kt @@ -59,7 +59,10 @@ internal fun UpcomingEventsScreen( detailPane = { AnimatedPane { navigator.currentDestination?.content?.let { - EventsDetailPane(it) + EventsDetailPane( + event = it, + onBackClick = navigator::navigateBack, + ) } } }, diff --git a/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Day_839dc042_0.png b/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Day_839dc042_0.png index fa0d98b3a..bd0192643 100644 Binary files a/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Day_839dc042_0.png and b/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Day_839dc042_0.png differ diff --git a/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Night_a6299619_0.png b/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Night_a6299619_0.png index c983522b0..bd0192643 100644 Binary files a/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Night_a6299619_0.png and b/conferences-app/src/debug/screenshotTest/reference/io/ashdavies/party/events/EventsDetailTests/EventsDetailPreview_Night_a6299619_0.png differ diff --git a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/events/EventsDetailTests.kt b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/events/EventsDetailTests.kt index 4ae5d1dff..71988703a 100644 --- a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/events/EventsDetailTests.kt +++ b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/events/EventsDetailTests.kt @@ -10,7 +10,10 @@ internal class EventsDetailTests { @PreviewDayNight private fun EventsDetailPreview() { MaterialPreviewTheme { - EventsDetailPane(DroidconBerlin) + EventsDetailPane( + event = DroidconBerlin, + onBackClick = { }, + ) } } } diff --git a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/tooling/MaterialPreviewTheme.kt b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/tooling/MaterialPreviewTheme.kt index a284756f9..9ab9e5357 100644 --- a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/tooling/MaterialPreviewTheme.kt +++ b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/tooling/MaterialPreviewTheme.kt @@ -9,20 +9,27 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import io.ashdavies.party.material.LocalWindowSizeClass +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.PreviewContextConfigurationEffect @Composable -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@OptIn(ExperimentalResourceApi::class) internal fun MaterialPreviewTheme( size: DpSize = DpSize(1280.dp, 720.dp), content: @Composable () -> Unit, ) { - MaterialTheme(if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { - CompositionLocalProvider( - value = LocalWindowSizeClass provides WindowSizeClass.calculateFromSize(size), - content = { Surface(content = content) }, - ) + CompositionLocalProvider(LocalInspectionMode provides true) { + PreviewContextConfigurationEffect() + + MaterialTheme(if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + CompositionLocalProvider(LocalWindowSizeClass provides WindowSizeClass.calculateFromSize(size)) { + Surface(content = content) + } + } } }