diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 62bd94e53c..5156ddcd2d 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.hedvig" versionCode = 43 - versionName = "14.4.2" + versionName = "14.4.3" resourceConfigurations.addAll(listOf("en", "sv-rSE")) } diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt new file mode 100644 index 0000000000..176ae3c9bc --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt @@ -0,0 +1,103 @@ +package com.hedvig.android.design.system.hedvig + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Scrolls the content horizontally when it is wider than the space it is given: left to reveal its + * end, a pause, then back to the start and another pause, looping. Content that already fits stays + * still. Scroll duration is proportional to the hidden overflow, so the speed stays constant rather + * than long content whipping past. Intended for a single, non-wrapping line of text. + * + * @param velocity how far the content travels per second while scrolling + * @param pause how long to hold at each end before scrolling the other way + */ +fun Modifier.autoScrollingMarquee( + velocity: Dp = DEFAULT_VELOCITY_PER_SECOND, + pause: Duration = DEFAULT_PAUSE, +): Modifier = clipToBounds() then AutoScrollingMarqueeElement(velocity, pause) + +private data class AutoScrollingMarqueeElement( + val velocity: Dp, + val pause: Duration, +) : ModifierNodeElement() { + override fun create() = AutoScrollingMarqueeNode(velocity, pause) + + override fun update(node: AutoScrollingMarqueeNode) = node.update(velocity, pause) + + override fun InspectorInfo.inspectableProperties() { + name = "autoScrollingMarquee" + properties["velocity"] = velocity + properties["pause"] = pause + } +} + +private class AutoScrollingMarqueeNode( + private var velocity: Dp, + private var pause: Duration, +) : Modifier.Node(), LayoutModifierNode { + private val offset = Animatable(0f) + private var overflow = 0f + private var animation: Job? = null + + fun update(velocity: Dp, pause: Duration) { + this.velocity = velocity + this.pause = pause + restart() + } + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints.copy(maxWidth = Constraints.Infinity)) + val width = placeable.width.coerceAtMost(constraints.maxWidth) + val newOverflow = (placeable.width - width).toFloat() + if (newOverflow != overflow) { + overflow = newOverflow + restart() + } + return layout(width, placeable.height) { + placeable.place(offset.value.roundToInt(), 0) + } + } + + private fun restart() { + animation?.cancel() + animation = coroutineScope.launch { + if (overflow <= 0f) { + offset.snapTo(0f) + return@launch + } + val durationMillis = (overflow / with(requireDensity()) { velocity.toPx() } * 1000f).roundToInt() + val scrollSpec = tween(durationMillis, easing = LinearEasing) + offset.snapTo(0f) + while (isActive) { + delay(pause) + offset.animateTo(-overflow, scrollSpec) + delay(pause) + offset.animateTo(0f, scrollSpec) + } + } + } +} + +private val DEFAULT_VELOCITY_PER_SECOND = 30.dp +private val DEFAULT_PAUSE = 1500.milliseconds diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index c21ffbad0c..46eb161bb9 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -19,8 +19,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader @@ -35,6 +38,7 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.autoScrollingMarquee import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback import com.hedvig.android.design.system.hedvig.rememberPreviewImageLoader import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell @@ -183,15 +187,14 @@ private fun OnboardingBundleScreen( ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - for ((index, crossSell) in state.crossSells.withIndex()) { - if (index > 0) { - Spacer(Modifier.height(8.dp)) + EqualHeightColumn(rowSpacing = 8.dp) { + for (crossSell in state.crossSells) { + OnboardingCrossSellRow( + crossSell = crossSell, + imageLoader = imageLoader, + openUrl = openUrl, + ) } - OnboardingCrossSellRow( - crossSell = crossSell, - imageLoader = imageLoader, - openUrl = openUrl, - ) } Spacer(Modifier.height(8.dp)) Spacer(Modifier.weight(1f)) @@ -231,15 +234,19 @@ private fun OnboardingCrossSellRow( } else { Spacer(Modifier.size(48.dp)) } - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f)) { HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) HedvigText( crossSell.description, style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, + maxLines = 1, + softWrap = false, + modifier = Modifier.autoScrollingMarquee(), ) } + Spacer(Modifier.width(16.dp)) HedvigButton( text = stringResource(Res.string.ONBOARDING_SEE_PRICE_BUTTON), onClick = { openUrl(crossSell.storeUrl) }, @@ -251,6 +258,28 @@ private fun OnboardingCrossSellRow( } } +/** + * Lays its children out in a vertical column where every child is given the height of the tallest + * child, with each child's own content centred within its cell. One measure pass, so the heights are + * uniform on the first frame. [rowSpacing] is inserted between children. + */ +@Composable +private fun EqualHeightColumn(rowSpacing: Dp, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + val rowHeight = placeables.maxOfOrNull(Placeable::height) ?: 0 + val spacingPx = rowSpacing.roundToPx() + val totalHeight = rowHeight * placeables.size + spacingPx * (placeables.size - 1).coerceAtLeast(0) + layout(constraints.maxWidth, totalHeight) { + var y = 0 + placeables.forEach { placeable -> + placeable.place(0, y + (rowHeight - placeable.height) / 2) + y += rowHeight + spacingPx + } + } + } +} + @HedvigPreview @Composable private fun PreviewOnboardingBundleScreen( @@ -280,19 +309,40 @@ private class OnboardingBundleUiStateProvider : CollectionPreviewParameterProvid progress = OnboardingProgress(totalSteps = 5, currentIndex = 1), crossSells = listOf( OnboardingCrossSell( - id = "accident", - title = "Accident Insurance", - description = "Coverage for accidental injuries", - storeUrl = "https://www.hedvig.com/se/forsakringar/olycksfallsforsakring", + id = "home", + title = "Home Insurance", + description = "For you, your family and your home", + storeUrl = "https://www.hedvig.com/se/forsakringar/hemforsakring", pillowImageUrl = null, ), OnboardingCrossSell( id = "pet", title = "Pet Insurance", - description = "Coverage for your pet", + description = "For your dog or cat", storeUrl = "https://www.hedvig.com/se/forsakringar/djurforsakring", pillowImageUrl = null, ), + OnboardingCrossSell( + id = "car", + title = "Car insurance", + description = "For you and your car", + storeUrl = "https://www.hedvig.com/se/forsakringar/bilforsakring", + pillowImageUrl = null, + ), + OnboardingCrossSell( + id = "vacation", + title = "Vacation Home Insurance", + description = "For your cottage or cabin", + storeUrl = "https://www.hedvig.com/se/forsakringar/fritidshusforsakring", + pillowImageUrl = null, + ), + OnboardingCrossSell( + id = "ppi", + title = "Payment Protection Insurance", + description = "For you if you get unemployed", + storeUrl = "https://www.hedvig.com/se/forsakringar/inkomstforsakring", + pillowImageUrl = null, + ), ), ), ), diff --git a/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml b/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml new file mode 100644 index 0000000000..699da1bff0 --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml @@ -0,0 +1,3 @@ + + +