Swipe between wizard steps and frame the previews (#163)

The steps now travel a full width on the Expressive spatial spring instead
of a fifth of one under a cross-fade, so moving through the wizard reads as
a swipe. The view previews get a tonal bezel — they draw the app's own
surface, so against a chooser drawing the same surface they had no edge of
their own — and a wider gap under them before the option rows. The frame is
shared with the Settings preview pickers so the same choice still looks the
same wherever it is made.
This commit is contained in:
2026-08-08 21:57:51 +02:00
parent 4ffeed8409
commit 3e68ed8068
6 changed files with 170 additions and 129 deletions

View File

@@ -27,12 +27,12 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */ /** What the root is showing: a wizard step, the app, or neither yet. */
@@ -130,12 +130,10 @@ fun RootScreen(
val slideDir = if (ordinal < lastOrdinal) -1 else 1 val slideDir = if (ordinal < lastOrdinal) -1 else 1
SideEffect { lastOrdinal = ordinal } SideEffect { lastOrdinal = ordinal }
// The wizard advances along M3's shared-axis X, the same transition adjacent // The wizard swipes: the steps sit side by side and the whole width travels,
// months and weeks use: the outgoing step slides and fades one way while the // so the flow reads as moving through a stack of screens rather than as one
// next arrives from the other. A plain cross-fade read as a smear here — // screen redrawing itself.
// both steps are opaque full-screen scaffolds, so their text dissolved val slideSpec = rememberOnboardingSlideSpec()
// through each other with nothing to say which way the flow was going.
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
AnimatedContent( AnimatedContent(
@@ -146,7 +144,7 @@ fun RootScreen(
if (initialState == RootTarget.Loading) { if (initialState == RootTarget.Loading) {
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
} else { } else {
calendarSlideTransition( onboardingSlideTransition(
slideDir = slideDir, slideDir = slideDir,
spec = slideSpec, spec = slideSpec,
fadeSpec = fadeSpec, fadeSpec = fadeSpec,

View File

@@ -1,12 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -18,6 +26,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -86,3 +95,54 @@ internal fun ScaledViewPreview(
Column(Modifier.fillMaxSize()) { content() } Column(Modifier.fillMaxSize()) { content() }
} }
} }
/**
* The bezel every preview picker frames its preview with, cross-fading as
* [selected] changes.
*
* A preview draws the app's own surface, so on a chooser screen — which draws the
* same surface — it had no edge of its own and dissolved into the page. The tonal
* ring gives it one: the preview reads as a screen being shown rather than as
* part of the screen showing it.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the frame rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
}
private val FRAME_INSET = 8.dp
private val FRAME_SHAPE = RoundedCornerShape(20.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
* composable scope so it can be captured by `AnimatedContent`'s non-composable
* transitionSpec lambda.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* Moving between wizard steps (#163): the steps sit side by side and the whole
* width swipes across, position on a spring so the arriving step settles with a
* little give rather than stopping dead.
*
* No cross-fade, unlike the calendar's shared-axis paging. Adjacent months are
* near-identical grids, so there the fade does the swapping and a short slide
* only hints the direction; here the two steps have nothing in common and fading
* one through the other read as a smear. Both steps are opaque and travel in
* lockstep, so a full-width slide shows one screen pushing the other off with no
* seam between them.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back.
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
* @param fadeSpec effects spec used for the whole transition under reduced
* motion.
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
internal fun onboardingSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return ContentTransform(
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
// Clipping to the animating container would shear both steps against the
// viewport edge as they pass; there is no size change here to contain.
sizeTransform = SizeTransform(clip = false),
)
}

View File

@@ -1,24 +1,15 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -28,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
@@ -42,7 +34,6 @@ import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** /**
@@ -196,40 +187,31 @@ private fun StepPickerScaffold(
) )
} }
/** The live preview, cross-fading as [selected] changes, framed like the picker's. */ /**
* The live preview, framed like the picker's. The gap under it is wider than the
* one above: the rows below are the things to tap, and they need to look like a
* group of their own rather than the preview's caption.
*/
@Composable @Composable
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) { private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
val reduceMotion = rememberReduceMotion() ViewPreviewFrame(
Box( selected = selected,
modifier = Modifier label = "step-preview",
.fillMaxWidth() modifier = Modifier.padding(
.padding(horizontal = 12.dp, vertical = 8.dp) horizontal = 12.dp,
.height(PREVIEW_HEIGHT), vertical = OnboardingSpace.xs,
contentAlignment = Alignment.Center, ).padding(bottom = OnboardingSpace.sm),
) { content = content,
Crossfade( )
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "step-preview",
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the frame rounds it.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
} }
/** /**
* Sized so the preview plus its frame occupies what the unframed preview used to:
* the bezel comes out of the picture, not out of the step, which still has to fit
* without scrolling.
*
* Smaller than the Settings chooser's preview: this screen also carries the step * Smaller than the Settings chooser's preview: this screen also carries the step
* counter and the Continue button, and the whole step has to fit without * counter and the Continue button, and the whole step has to fit without
* scrolling. * scrolling.
*/ */
private val PREVIEW_HEIGHT: Dp = 200.dp private val PREVIEW_HEIGHT: Dp = 184.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -1,24 +1,13 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.descriptionRes
@@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
/** /**
@@ -51,39 +39,21 @@ internal fun MonthViewStylePicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val options = MonthViewStyle.entries val options = MonthViewStyle.entries
val reduceMotion = rememberReduceMotion()
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.settings_month_view_style), title = stringResource(R.string.settings_month_view_style),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true, predictiveBack = true,
) { ) {
Box( ViewPreviewFrame(
modifier = Modifier selected = selected,
.fillMaxWidth() label = "month-style-preview",
.padding(horizontal = 12.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
.height(PREVIEW_HEIGHT), ) { shown ->
contentAlignment = Alignment.Center, MonthStylePreview(
) { style = shown,
Crossfade( weekStart = weekStart,
targetState = selected, height = PREVIEW_HEIGHT,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), )
label = "month-style-preview",
) { shown ->
// The preview renders the real grid at phone size and scales it
// down, so its own corners are square — the frame rounds it.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview(
style = shown,
weekStart = weekStart,
height = PREVIEW_HEIGHT,
)
}
}
} }
// The selected style's own blurb lives here, under its preview, rather // The selected style's own blurb lives here, under its preview, rather
// than on every row — the rows stay single-line and dense, and the words // than on every row — the rows stay single-line and dense, and the words
@@ -109,4 +79,3 @@ internal fun MonthViewStylePicker(
} }
private val PREVIEW_HEIGHT = 280.dp private val PREVIEW_HEIGHT = 280.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -1,33 +1,21 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** /**
@@ -46,38 +34,22 @@ internal fun WeekStartPicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val resolved = selected.resolveFirstDay(locale) val resolved = selected.resolveFirstDay(locale)
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true, predictiveBack = true,
) { ) {
Box( ViewPreviewFrame(
modifier = Modifier selected = resolved,
.fillMaxWidth() label = "week-start-preview",
.padding(horizontal = 12.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
.height(PREVIEW_HEIGHT), ) { day ->
contentAlignment = Alignment.Center, MonthStylePreview(
) { style = monthStyle,
Crossfade( weekStart = day,
targetState = resolved, height = PREVIEW_HEIGHT,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), )
label = "week-start-preview",
) { day ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview(
style = monthStyle,
weekStart = day,
height = PREVIEW_HEIGHT,
)
}
}
} }
PickerDescription(stringResource(R.string.settings_week_start_hint)) PickerDescription(stringResource(R.string.settings_week_start_hint))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
@@ -107,4 +79,3 @@ internal fun WeekStartPicker(
/** Short enough to leave the first options on screen under it. */ /** Short enough to leave the first options on screen under it. */
private val PREVIEW_HEIGHT = 200.dp private val PREVIEW_HEIGHT = 200.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)