From 64ed51ccae3dc7db2bf46831aef90a51e2f11621 Mon Sep 17 00:00:00 2001 From: amjiao Date: Wed, 14 Oct 2026 17:40:25 -0400 Subject: [PATCH 1/7] first draft of onboarding animation --- .../com/cornellappdev/score/MainActivity.kt | 8 +- .../score/components/OnboardingButtons.kt | 196 ++++++ .../OnboardingAnimationPreviewScreen.kt | 141 ++++ .../score/screen/OnboardingScreen.kt.kt | 645 ++++++++++++++++++ 4 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt create mode 100644 app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt create mode 100644 app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt diff --git a/app/src/main/java/com/cornellappdev/score/MainActivity.kt b/app/src/main/java/com/cornellappdev/score/MainActivity.kt index 830d3aa..a9b44ff 100644 --- a/app/src/main/java/com/cornellappdev/score/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/score/MainActivity.kt @@ -5,6 +5,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import com.cornellappdev.score.nav.root.RootNavigation +import com.cornellappdev.score.screen.OnboardingAnimationPreviewScreen import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics @@ -18,7 +19,12 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - RootNavigation() + //RootNavigation() + OnboardingAnimationPreviewScreen( + onSignUp = {}, + onLogIn = {}, + onLater = {} + ) } firebaseAnalytics = Firebase.analytics } diff --git a/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt new file mode 100644 index 0000000..9619b37 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt @@ -0,0 +1,196 @@ +package com.cornellappdev.score.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cornellappdev.score.theme.CrimsonPrimary +import com.cornellappdev.score.theme.Style.heading3 +import com.cornellappdev.score.theme.Style.labelsNormal +import com.cornellappdev.score.theme.White +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun OnboardingButtons( + onSignUp: () -> Unit, + onLogIn: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier +) { + var showSignUp by remember { mutableStateOf(false) } + var showLogIn by remember { mutableStateOf(false) } + var showLater by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + showSignUp = true + delay(120.milliseconds) + showLogIn = true + delay(120.milliseconds) + showLater = true + } + + OnboardingButtonsContent( + onSignUp = onSignUp, + onLogIn = onLogIn, + onLater = onLater, + showSignUp = showSignUp, + showLogIn = showLogIn, + showLater = showLater, + animateEntrance = true, + modifier = modifier + ) +} + +@Composable +private fun OnboardingButtonsContent( + onSignUp: () -> Unit, + onLogIn: () -> Unit, + onLater: () -> Unit, + showSignUp: Boolean, + showLogIn: Boolean, + showLater: Boolean, + animateEntrance: Boolean, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OnboardingButtonSlot( + modifier = Modifier.fillMaxWidth().height(50.dp), + visible = showSignUp, + animateEntrance = animateEntrance + ) { + Button( + onClick = onSignUp, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(100), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = CrimsonPrimary + ) + ) { + Text( + text = "Sign Up", + style = heading3, + color = CrimsonPrimary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OnboardingButtonSlot( + modifier = Modifier.fillMaxWidth().height(48.dp), + visible = showLogIn, + animateEntrance = animateEntrance + ) { + OutlinedButton( + onClick = onLogIn, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(100), + border = BorderStroke( + width = 1.dp, + color = Color.White.copy(alpha = 0.9f) + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ) + ) { + Text( + text = "Log In", + style = heading3, + color = White + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + OnboardingButtonSlot( + modifier = Modifier.fillMaxWidth().height(40.dp), + visible = showLater, + animateEntrance = animateEntrance + ) { + TextButton( + onClick = onLater, + colors = ButtonDefaults.textButtonColors(contentColor = Color.White) + ) { + Text( + text = "Sign Up Later", + style = labelsNormal + ) + } + } + } +} + +@Composable +private fun OnboardingButtonSlot( + visible: Boolean, + animateEntrance: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + if (animateEntrance) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(350)) + slideInVertically( + initialOffsetY = { it / 3 }, + animationSpec = tween(400) + ) + ) { + content() + } + } else if (visible) { + content() + } + } +} + +@Preview(showBackground = true, widthDp = 393) +@Composable +private fun OnboardingButtonsPreview() = ScorePreview( + padding = 24.dp, + backgroundColor = CrimsonPrimary +) { + OnboardingButtonsContent( + onSignUp = {}, + onLogIn = {}, + onLater = {}, + showSignUp = true, + showLogIn = true, + showLater = true, + animateEntrance = false + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt new file mode 100644 index 0000000..25998ca --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt @@ -0,0 +1,141 @@ +package com.cornellappdev.score.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.unit.dp +import com.cornellappdev.score.components.OnboardingButtons +import com.cornellappdev.score.theme.CrimsonPrimary +import com.yourpackage.ui.onboarding.Speaker +import com.yourpackage.ui.onboarding.SportsIconAnimation +import kotlinx.coroutines.delay + +@Composable +fun OnboardingAnimationPreviewScreen( + onSignUp: () -> Unit, + onLogIn: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier +) { + var animationKey by remember { mutableIntStateOf(0) } + + Box(modifier = modifier.fillMaxSize()) { + OnboardingAnimation( + animationKey = animationKey + ) + + Button( + onClick = { + animationKey++ + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 56.dp, end = 16.dp) + ) { + Text("Replay") + } + } +} + +@Composable +private fun OnboardingAnimation( + animationKey: Int +) { + var showIcons by remember(animationKey) { + mutableStateOf(false) + } + + var showRings by remember(animationKey) { + mutableStateOf(true) + } + + var showButtons by remember(animationKey) { + mutableStateOf(false) + } + + var moveSpeakerUp by remember(animationKey) { + mutableStateOf(false) + } + + LaunchedEffect(animationKey) { + showIcons = false + showRings = true + showButtons = false + moveSpeakerUp = false + + delay(350) + showIcons = true + + delay(2550) + showIcons = false + showRings = false + + delay(1300) + moveSpeakerUp = true + + delay(700) + showButtons = true + } + + // Your existing Box containing: + // + // SportsIconAnimation(...) + // Megaphone(...) + // OnboardingButtons(...) + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = CrimsonPrimary + ) + ) { + + // Sports icons spread outward and fade as the megaphone grows. + SportsIconAnimation( + modifier = Modifier.fillMaxSize(), + visible = showIcons + ) + + // Speaker + expanding circles + Speaker( + moveUp = moveSpeakerUp, + showRings = showRings, + modifier = Modifier.align(Alignment.Center) + ) + + if (showButtons) { + OnboardingButtons( + onSignUp = { }, + onLogIn = { }, + onLater = { }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding( + start = 24.dp, + end = 24.dp, + bottom = 48.dp + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt new file mode 100644 index 0000000..a00f500 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt @@ -0,0 +1,645 @@ +package com.yourpackage.ui.onboarding + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +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.platform.LocalDensity +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.sp +import com.cornellappdev.score.R +import com.cornellappdev.score.components.OnboardingButtons +import com.cornellappdev.score.theme.CrimsonPrimary +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +private enum class OnboardingAnimationPhase { + Initial, + Icons, + IconsFading, + SpeakerTransition, + Complete +} + +@Composable +fun OnboardingScreen( + onSignUp: () -> Unit, + onLogIn: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier +) { + var showIcons by remember { mutableStateOf(false) } + var showRings by remember { mutableStateOf(true) } + var showButtons by remember { mutableStateOf(false) } + var moveSpeakerUp by remember { mutableStateOf(false) } + + /* + * Timeline based on the Figma animation: + * + * 0ms -> red background + * ~300ms -> megaphone/rings start + * ~400ms -> sports icons start appearing + * ~2500ms -> icons finish + * ~3000ms -> icons fade out + * ~3200ms -> megaphone moves upward + * ~3400ms -> buttons appear + */ + LaunchedEffect(Unit) { + delay(350) + showIcons = true + + delay(2550) + showIcons = false + showRings = false + + delay(1300) + moveSpeakerUp = true + + delay(700) + showButtons = true + } + + Box( + modifier = modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + Color(0xFFB52A12), + CrimsonPrimary, + Color(0xFF941B0A) + ) + ) + ) + ) { + + // Sports icons spread outward and fade as the megaphone grows. + SportsIconAnimation( + modifier = Modifier.fillMaxSize(), + visible = showIcons + ) + + // Speaker + expanding circles + Speaker( + moveUp = moveSpeakerUp, + showRings = showRings, + modifier = Modifier.align(Alignment.Center) + ) + + if (showButtons) { + OnboardingButtons( + onSignUp = onSignUp, + onLogIn = onLogIn, + onLater = onLater, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding( + start = 24.dp, + end = 24.dp, + bottom = 48.dp + ) + ) + } + } +} + +@Composable +fun Speaker( + moveUp: Boolean, + modifier: Modifier = Modifier, + showRings: Boolean = true +) { + val transition = updateTransition( + targetState = moveUp, + label = "speakerTransition" + ) + + val verticalOffset by transition.animateFloat( + transitionSpec = { + tween( + durationMillis = 700, + easing = FastOutSlowInEasing + ) + }, + label = "speakerY" + ) { isUp -> + if (isUp) -120f else 0f + } + + val scale by transition.animateFloat( + transitionSpec = { + tween( + durationMillis = 700, + easing = FastOutSlowInEasing + ) + }, + label = "speakerScale" + ) { isUp -> + if (isUp) 1.45f else 1f // this is centered + } + + val iconSize by transition.animateDp( + transitionSpec = { + tween( + durationMillis = 700, + easing = FastOutSlowInEasing + ) + }, + label = "speakerIconSize" + ) { isUp -> + if (isUp) 136.dp else 132.dp + } + + Box( + modifier = modifier + .offset { + IntOffset( + x = 0, + y = verticalOffset.roundToInt() + ) + } + .scale(scale) + .size(190.dp), + contentAlignment = Alignment.Center + ) { + + // Expanding circles + val infiniteTransition = rememberInfiniteTransition( + label = "rings" + ) + + val scale1 by infiniteTransition.animateFloat( + initialValue = 0.75f, + targetValue = 1.25f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "ring1" + ) + + val scale2 by infiniteTransition.animateFloat( + initialValue = 0.65f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + delayMillis = 450, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "ring2" + ) + + val scale3 by infiniteTransition.animateFloat( + initialValue = 0.55f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + delayMillis = 900, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "ring3" + ) + + AnimatedVisibility( + visible = showRings, + enter = fadeIn(tween(250)), + exit = fadeOut(tween(700)) + ) { + Box(contentAlignment = Alignment.Center) { + Ring( + scale = scale1, + modifier = Modifier.size(190.dp) + ) + + Ring( + scale = scale2, + modifier = Modifier.size(190.dp) + ) + + Ring( + scale = scale3, + modifier = Modifier.size(190.dp) + ) + } + } + + Image( + painter = painterResource(R.drawable.ic_speaker), + contentDescription = null, + modifier = Modifier.size(iconSize), + colorFilter = ColorFilter.tint(Color.White) + ) + } +} + +@Composable +private fun Ring( + scale: Float, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .scale(scale) + .clip(shape = CircleShape) + .background(color = Color.White.copy(alpha = 0.055f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.15f), + shape = CircleShape + ) + ) +} + +private data class SportsIcon( + @DrawableRes val drawable: Int, + val x: Float, + val y: Float, + val delay: Int, + val rotation: Float = 0f +) + +private val sportsIcons = listOf( + // Top row + SportsIcon( + drawable = R.drawable.ic_rowing_lightweight, + x = -0.22f, + y = -0.43f, + delay = 0 + ), + SportsIcon( + drawable = R.drawable.ic_field_hockey, + x = 0.10f, + y = -0.47f, + delay = 100 + ), + SportsIcon( + drawable = R.drawable.ic_wrestling, + x = 0.38f, + y = -0.40f, + delay = 200 + ), + + // Upper-middle + SportsIcon( + drawable = R.drawable.ic_basketball, + x = -0.03f, + y = -0.29f, + delay = 250 + ), + SportsIcon( + drawable = R.drawable.ic_tennis, + x = 0.27f, + y = -0.27f, + delay = 350 + ), + SportsIcon( + drawable = R.drawable.ic_volleyball, + x = -0.40f, + y = -0.22f, + delay = 400 + ), + SportsIcon( + drawable = R.drawable.ic_soccer, + x = 0.47f, + y = -0.17f, + delay = 500 + ), + + // Middle + SportsIcon( + drawable = R.drawable.ic_track_field, + x = -0.48f, + y = 0.02f, + delay = 550 + ), + SportsIcon( + drawable = R.drawable.ic_lacrosse, + x = 0.48f, + y = 0.05f, + delay = 650 + ), + + // Lower-middle + SportsIcon( + drawable = R.drawable.ic_swim_dive, + x = -0.40f, + y = 0.22f, + delay = 700 + ), + SportsIcon( + drawable = R.drawable.ic_field_hockey, + x = 0.23f, + y = 0.22f, + delay = 800 + ), + SportsIcon( + drawable = R.drawable.ic_baseball, + x = 0.43f, + y = 0.27f, + delay = 900 + ), + + // Bottom + SportsIcon( + drawable = R.drawable.ic_tennis, + x = -0.34f, + y = 0.40f, + delay = 1000 + ), + SportsIcon( + drawable = R.drawable.ic_golf, + x = 0.02f, + y = 0.42f, + delay = 1100 + ), + SportsIcon( + drawable = R.drawable.ic_football, + x = 0.35f, + y = 0.43f, + delay = 1200 + ) +) + +@Composable +fun SportsIconAnimation( + modifier: Modifier = Modifier, + visible: Boolean = true +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val animationSize = with(LocalDensity.current) { maxWidth.toPx() } + + sportsIcons.forEach { icon -> + AnimatedSportsIcon( + icon = icon, + animationSize = animationSize, + visible = visible + ) + } + } +} + +@Composable +private fun AnimatedSportsIcon( + icon: SportsIcon, + animationSize: Float, + previewProgress: Float? = null, + visible: Boolean = true +) { + var started by remember { mutableStateOf(false) } + + LaunchedEffect(visible) { + if (visible && !started) { + delay(icon.delay.toLong()) + started = true + } + } + + val iconState = when { + !started -> 0 + visible -> 1 + else -> 2 + } + + val transition = updateTransition( + targetState = iconState, + label = "iconTransition" + ) + + val animatedProgress by transition.animateFloat( + transitionSpec = { + if (targetState == 1) { + spring(dampingRatio = 0.55f, stiffness = 500f) + } else { + tween( + durationMillis = if (targetState == 2) 1300 else 900, + easing = FastOutSlowInEasing + ) + } + }, + label = "iconProgress" + ) { state -> + when (state) { + 0 -> 0f + 1 -> 1f + else -> 1.6f + } + } + + val entranceWiggle = if ((icon.delay / 100) % 2 == 0) -18f else 18f + + val animatedScale by transition.animateFloat( + transitionSpec = { + if (targetState == 1) { + spring(dampingRatio = 0.45f, stiffness = 600f) + } else { + tween(durationMillis = 250) + } + }, + label = "iconScale" + ) { state -> + when (state) { + 0 -> 0.35f + else -> 1f + } + } + + val animatedRotation by transition.animateFloat( + transitionSpec = { + if (targetState == 1) { + spring(dampingRatio = 0.4f, stiffness = 500f) + } else { + tween(durationMillis = 250) + } + }, + label = "iconRotation" + ) { state -> + if (state == 0) icon.rotation + entranceWiggle else icon.rotation + } + + val animatedAlpha by transition.animateFloat( + transitionSpec = { + if (targetState == 2) { + keyframes { + durationMillis = 1300 + 1f at 0 + 1f at 350 + 0f at 1300 + } + } else { + tween(durationMillis = 500) + } + }, + label = "iconAlpha" + ) { state -> + if (state == 1) 1f else 0f + } + + val progress = previewProgress ?: animatedProgress + val alpha = if (previewProgress != null) { + if (previewProgress > 0f) 1f else 0f + } else { + animatedAlpha + } + val scale = if (previewProgress != null) { + if (previewProgress > 0f) 1f else 0.35f + } else { + animatedScale + } + val rotation = if (previewProgress != null) icon.rotation else animatedRotation + + val x = icon.x * animationSize * progress + val y = icon.y * 1.15f * animationSize * progress + + Image( + painter = painterResource(icon.drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier + .size(28.5.dp) + .alpha(alpha) + .scale(scale) + .rotate(rotation) + .offset { + IntOffset( + x = x.roundToInt(), + y = y.roundToInt() + ) + } + ) +} + +@Preview( + name = "Speaker - Center", + showBackground = true, + backgroundColor = 0xFFA3200C +) +@Composable +private fun SpeakerPreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background(CrimsonPrimary), + contentAlignment = Alignment.Center + ) { + Speaker( + moveUp = false + ) + } +} + +@Preview( + name = "Speaker - Moved Up", + showBackground = true, + backgroundColor = 0xFFA3200C +) +@Composable +private fun SpeakerMovedPreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background(CrimsonPrimary), + contentAlignment = Alignment.Center + ) { + Speaker( + moveUp = true + ) + } +} + +@Preview( + name = "All Sports Icons", + showBackground = true, + backgroundColor = 0xFFA3200C, + widthDp = 394, + heightDp = 852 +) +@Composable +private fun SportsIconAnimationPreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background(CrimsonPrimary), + contentAlignment = Alignment.Center + ) { + sportsIcons.forEach { icon -> + AnimatedSportsIcon( + icon = icon, + 1f + ) + } + + Image( + painter = painterResource(R.drawable.ic_speaker), + contentDescription = null, + modifier = Modifier.size(132.dp), + colorFilter = ColorFilter.tint(Color.White) + ) + } +} \ No newline at end of file From f7bd25d3ee92f2f98d092a40ea478cabc523043c Mon Sep 17 00:00:00 2001 From: amjiao Date: Wed, 14 Oct 2026 18:14:06 -0400 Subject: [PATCH 2/7] Complete animation --- .../score/components/OnboardingButtons.kt | 27 +- .../OnboardingAnimationPreviewScreen.kt | 111 +-- .../score/screen/OnboardingScreen.kt | 459 +++++++++++++ .../score/screen/OnboardingScreen.kt.kt | 645 ------------------ 4 files changed, 486 insertions(+), 756 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt delete mode 100644 app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt diff --git a/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt index 9619b37..a03f591 100644 --- a/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt +++ b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt @@ -49,9 +49,9 @@ fun OnboardingButtons( LaunchedEffect(Unit) { showSignUp = true - delay(120.milliseconds) + delay(160.milliseconds) showLogIn = true - delay(120.milliseconds) + delay(180.milliseconds) showLater = true } @@ -85,7 +85,9 @@ private fun OnboardingButtonsContent( OnboardingButtonSlot( modifier = Modifier.fillMaxWidth().height(50.dp), visible = showSignUp, - animateEntrance = animateEntrance + animateEntrance = animateEntrance, + initialOffset = 26.dp, + durationMillis = 340 ) { Button( onClick = onSignUp, @@ -109,7 +111,9 @@ private fun OnboardingButtonsContent( OnboardingButtonSlot( modifier = Modifier.fillMaxWidth().height(48.dp), visible = showLogIn, - animateEntrance = animateEntrance + animateEntrance = animateEntrance, + initialOffset = 24.dp, + durationMillis = 340 ) { OutlinedButton( onClick = onLogIn, @@ -136,7 +140,9 @@ private fun OnboardingButtonsContent( OnboardingButtonSlot( modifier = Modifier.fillMaxWidth().height(40.dp), visible = showLater, - animateEntrance = animateEntrance + animateEntrance = animateEntrance, + initialOffset = 18.dp, + durationMillis = 320 ) { TextButton( onClick = onLater, @@ -155,9 +161,14 @@ private fun OnboardingButtonsContent( private fun OnboardingButtonSlot( visible: Boolean, animateEntrance: Boolean, + initialOffset: androidx.compose.ui.unit.Dp, + durationMillis: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { + val initialOffsetPx = with(androidx.compose.ui.platform.LocalDensity.current) { + initialOffset.roundToPx() + } Box( modifier = modifier, contentAlignment = Alignment.Center @@ -165,9 +176,9 @@ private fun OnboardingButtonSlot( if (animateEntrance) { AnimatedVisibility( visible = visible, - enter = fadeIn(tween(350)) + slideInVertically( - initialOffsetY = { it / 3 }, - animationSpec = tween(400) + enter = fadeIn(tween(durationMillis)) + slideInVertically( + initialOffsetY = { initialOffsetPx }, + animationSpec = tween(durationMillis) ) ) { content() diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt index 25998ca..a2505d8 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt @@ -1,35 +1,22 @@ package com.cornellappdev.score.screen -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.background -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -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.unit.dp -import com.cornellappdev.score.components.OnboardingButtons -import com.cornellappdev.score.theme.CrimsonPrimary -import com.yourpackage.ui.onboarding.Speaker -import com.yourpackage.ui.onboarding.SportsIconAnimation -import kotlinx.coroutines.delay +/** + * Preview host for replaying the same onboarding animation used by the real screen. + */ @Composable fun OnboardingAnimationPreviewScreen( onSignUp: () -> Unit, @@ -40,14 +27,15 @@ fun OnboardingAnimationPreviewScreen( var animationKey by remember { mutableIntStateOf(0) } Box(modifier = modifier.fillMaxSize()) { - OnboardingAnimation( + OnboardingScreen( + onSignUp = onSignUp, + onLogIn = onLogIn, + onLater = onLater, animationKey = animationKey ) Button( - onClick = { - animationKey++ - }, + onClick = { animationKey++ }, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 56.dp, end = 16.dp) @@ -56,86 +44,3 @@ fun OnboardingAnimationPreviewScreen( } } } - -@Composable -private fun OnboardingAnimation( - animationKey: Int -) { - var showIcons by remember(animationKey) { - mutableStateOf(false) - } - - var showRings by remember(animationKey) { - mutableStateOf(true) - } - - var showButtons by remember(animationKey) { - mutableStateOf(false) - } - - var moveSpeakerUp by remember(animationKey) { - mutableStateOf(false) - } - - LaunchedEffect(animationKey) { - showIcons = false - showRings = true - showButtons = false - moveSpeakerUp = false - - delay(350) - showIcons = true - - delay(2550) - showIcons = false - showRings = false - - delay(1300) - moveSpeakerUp = true - - delay(700) - showButtons = true - } - - // Your existing Box containing: - // - // SportsIconAnimation(...) - // Megaphone(...) - // OnboardingButtons(...) - Box( - modifier = Modifier - .fillMaxSize() - .background( - color = CrimsonPrimary - ) - ) { - - // Sports icons spread outward and fade as the megaphone grows. - SportsIconAnimation( - modifier = Modifier.fillMaxSize(), - visible = showIcons - ) - - // Speaker + expanding circles - Speaker( - moveUp = moveSpeakerUp, - showRings = showRings, - modifier = Modifier.align(Alignment.Center) - ) - - if (showButtons) { - OnboardingButtons( - onSignUp = { }, - onLogIn = { }, - onLater = { }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding( - start = 24.dp, - end = 24.dp, - bottom = 48.dp - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt new file mode 100644 index 0000000..eb5f254 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt @@ -0,0 +1,459 @@ +package com.cornellappdev.score.screen + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.cornellappdev.score.R +import com.cornellappdev.score.components.OnboardingButtons +import com.cornellappdev.score.theme.CrimsonPrimary +import kotlin.math.min + +private const val TIMELINE_DURATION_MS = 3_850 +private const val ICONS_HOLD_END_MS = 2_420 +private const val ICONS_FADE_END_MS = 3_020 +private const val ICONS_EXIT_END_MS = 3_080 + +private data class MotionKey(val timeMs: Float, val value: Float, val easing: Easing = LinearEasing) + +private fun sampleTrack(timeMs: Float, keys: List): Float { + if (timeMs <= keys.first().timeMs) return keys.first().value + for (index in 0 until keys.lastIndex) { + val start = keys[index] + val end = keys[index + 1] + if (timeMs <= end.timeMs) { + val fraction = ((timeMs - start.timeMs) / (end.timeMs - start.timeMs)).coerceIn(0f, 1f) + return start.value + (end.value - start.value) * start.easing.transform(fraction) + } + } + return keys.last().value +} + +private data class SportMotion( + @DrawableRes val drawable: Int, + val left: Float, + val top: Float, + val enterAtMs: Float, + val peakAlpha: Float, + val entryX: Float, + val entryY: Float, + val bounceX: Float, + val bounceY: Float, + val settleX: Float, + val settleY: Float, + val initialRotation: Float +) + +private val sportMotions = listOf( + SportMotion(R.drawable.ic_basketball, 172f, 237f, 340f, .94f, 5f, 141f, 13.441f, -16.006f, -6.071f, 6.568f, -18f), + SportMotion(R.drawable.ic_baseball, 285f, 299f, 385f, .94f, -108f, 79f, 3.615f, -19.99f, -1.141f, 8.64f, 18f), + SportMotion(R.drawable.ic_cross_country, 20f, 331f, 430f, .94f, 157f, 47f, -13.255f, -18.582f, 5.258f, 8.15f, -18f), + SportMotion(R.drawable.ic_football, 316f, 441f, 475f, .94f, -139f, -63f, 21.069f, -5.821f, -8.856f, 2.903f, 18f), + SportMotion(R.drawable.ic_golf, 176f, 526f, 520f, .94f, 1f, -148f, -14.11f, 16.185f, 6.345f, -6.617f, -18f), + SportMotion(R.drawable.ic_volleyball, 20f, 482f, 565f, .94f, 157f, -104f, -9.539f, 23.112f, 3.586f, -9.932f, 18f), + SportMotion(R.drawable.ic_ice_hockey, 220f, 179f, 580f, .8f, -43f, 199f, 18.414f, -18.933f, -8.093f, 7.624f, -18f), + SportMotion(R.drawable.ic_swim_dive, 259f, 500f, 610f, .94f, -82f, -122f, -2.599f, 21.23f, 1.539f, -9.004f, -18f), + SportMotion(R.drawable.ic_soccer, 251f, 240f, 625f, .8f, -74f, 138f, -4.198f, -21.796f, 2.222f, 9.187f, 18f), + SportMotion(R.drawable.ic_tennis, 334f, 526f, 670f, .8f, -157f, -148f, 7.667f, 26.467f, -2.744f, -11.244f, -18f), + SportMotion(R.drawable.ic_lacrosse, 255f, 582f, 715f, .8f, -78f, -204f, 21.657f, 17.44f, -9.395f, -6.93f, 18f), + SportMotion(R.drawable.ic_wrestling, 94f, 508f, 760f, .8f, 83f, -130f, -20.93f, 6.766f, 9.045f, -2.46f, -18f), + SportMotion(R.drawable.ic_fencing, 23f, 223f, 805f, .8f, 154f, 155f, -26.871f, -7.183f, 11.399f, 2.535f, 18f), + SportMotion(R.drawable.ic_softball, 41f, 575f, 820f, .66f, 136f, -197f, -26.481f, 13.716f, 11.305f, -5.286f, -18f), + SportMotion(R.drawable.ic_sailing, 72f, 263f, 850f, .8f, 105f, 115f, -1.211f, -22.09f, .073f, 9.423f, -18f), + SportMotion(R.drawable.ic_equestrian, 142f, 591f, 865f, .66f, 35f, -213f, 9.965f, 25.7f, -4.642f, -10.607f, 18f), + SportMotion(R.drawable.ic_field_hockey, 334f, 355f, 910f, .66f, -157f, 23f, 19.299f, 11.322f, -7.978f, -5.198f, -18f), + SportMotion(R.drawable.ic_gymnastics, 338f, 257f, 955f, .66f, -161f, 121f, 9.299f, -24.502f, -3.46f, 10.481f, 18f), + SportMotion(R.drawable.ic_squash, 61f, 415f, 1_000f, .66f, 116f, -37f, -17.014f, -9.268f, 7.134f, 4.337f, -18f), + SportMotion(R.drawable.ic_rowing_lightweight, 101f, 191f, 1_045f, .66f, 76f, 187f, -21.33f, -15.299f, 9.256f, 6.043f, 18f), + SportMotion(R.drawable.ic_track_field, 302f, 201f, 1_090f, .66f, -125f, 177f, 25.186f, -11.394f, -10.771f, 4.331f, -18f) +) + +@Composable +fun OnboardingScreen( + onSignUp: () -> Unit, + onLogIn: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier, + animationKey: Int = 0 +) { + val playhead = remember(animationKey) { Animatable(0f) } + LaunchedEffect(animationKey) { + playhead.snapTo(0f) + playhead.animateTo( + TIMELINE_DURATION_MS.toFloat(), + animationSpec = tween(TIMELINE_DURATION_MS, easing = LinearEasing) + ) + } + val timeMs = playhead.value + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(CrimsonPrimary) + ) { + val designScale = min(maxWidth.value / 393f, maxHeight.value / 852f) + val designDp: (Float) -> Dp = { (it * designScale).dp } + + FigmaSpeaker( + timeMs = timeMs, + left = 98f, + top = 229f, + size = 197f, + designDp = designDp, + alphaKeys = listOf(MotionKey(0f, 0f), MotionKey(2_760f, 0f), MotionKey(3_280f, 1f)), + scaleKeys = listOf(MotionKey(0f, .67f), MotionKey(2_760f, .67f), MotionKey(3_280f, 1f)), + yKeys = listOf(MotionKey(0f, 68.5f), MotionKey(2_760f, 68.5f), MotionKey(3_280f, 0f, FastOutSlowInEasing)) + ) + + if (timeMs >= 3_020f) { + OnboardingButtons( + onSignUp = onSignUp, + onLogIn = onLogIn, + onLater = onLater, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(start = 24.dp, end = 24.dp, bottom = 40.dp) + ) + } + + FigmaHalo( + timeMs = timeMs, + designDp = designDp + ) + FigmaRing( + timeMs = timeMs, + designDp = designDp, + delayMs = 240f, + opacityPeak = .72f, + opacityInEnd = 420f, + opacityOutStart = 420f, + opacityOutEnd = 1_020f, + scalePeak = 1.55f, + scalePeakTime = 1_020f, + scaleExit = 2.092f + ) + FigmaRing( + timeMs = timeMs, + designDp = designDp, + delayMs = 420f, + opacityPeak = .5f, + opacityInEnd = 620f, + opacityOutStart = 620f, + opacityOutEnd = 1_260f, + scalePeak = 1.72f, + scalePeakTime = 1_260f, + scaleExit = 2.322f + ) + + FigmaSpeaker( + timeMs = timeMs, + left = 129f, + top = 330f, + size = 132f, + designDp = designDp, + alphaKeys = listOf( + MotionKey(0f, 0f), + MotionKey(180f, 1f, FastOutSlowInEasing), + MotionKey(3_120f, 1f), + MotionKey(3_340f, 0f, FastOutLinearInEasing) + ), + scaleKeys = listOf( + MotionKey(0f, .54f), + MotionKey(420f, 1.17f, FastOutSlowInEasing), + MotionKey(630f, .94f, FastOutSlowInEasing), + MotionKey(820f, 1f, FastOutSlowInEasing), + MotionKey(2_420f, 1f), + MotionKey(3_220f, 1.492f, FastOutSlowInEasing) + ), + yKeys = listOf( + MotionKey(0f, 34f), + MotionKey(420f, -9f, FastOutSlowInEasing), + MotionKey(630f, 4f, FastOutSlowInEasing), + MotionKey(820f, 0f, FastOutSlowInEasing), + MotionKey(2_420f, 0f), + MotionKey(3_220f, -68.5f, FastOutSlowInEasing) + ), + rotationKeys = listOf( + MotionKey(0f, 9f), + MotionKey(420f, -6f, FastOutSlowInEasing), + MotionKey(630f, 3f, FastOutSlowInEasing), + MotionKey(820f, 0f, FastOutSlowInEasing) + ) + ) + + sportMotions.forEach { sport -> + FigmaSportIcon(timeMs = timeMs, motion = sport, designDp = designDp) + } + } +} + +@Composable +private fun FigmaSpeaker( + timeMs: Float, + left: Float, + top: Float, + size: Float, + designDp: (Float) -> Dp, + alphaKeys: List, + scaleKeys: List, + yKeys: List, + rotationKeys: List = listOf(MotionKey(0f, 0f)) +) { + val y = sampleTrack(timeMs, yKeys) + val alpha = sampleTrack(timeMs, alphaKeys).coerceIn(0f, 1f) + val scale = sampleTrack(timeMs, scaleKeys) + val rotation = sampleTrack(timeMs, rotationKeys) + + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(R.drawable.ic_speaker), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier + .align(Alignment.Center) + .offset( + x = designDp(left + size / 2f - 196.5f), + y = designDp(top + size / 2f - 426f + y) + ) + .size(designDp(size)) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + rotationZ = rotation + } + ) + } +} + +@Composable +private fun FigmaHalo(timeMs: Float, designDp: (Float) -> Dp) { + val alpha = sampleTrack( + timeMs, + listOf( + MotionKey(0f, 0f), + MotionKey(240f, 1f, FastOutSlowInEasing), + MotionKey(1_160f, .72f), + MotionKey(2_420f, .72f), + MotionKey(2_920f, 0f, FastOutLinearInEasing) + ) + ) + val scale = sampleTrack( + timeMs, + listOf( + MotionKey(0f, .62f), + MotionKey(460f, 1.12f, FastOutSlowInEasing), + MotionKey(840f, 1f, FastOutSlowInEasing), + MotionKey(2_420f, 1f), + MotionKey(2_980f, 1.35f, FastOutSlowInEasing) + ) + ) + Box(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .align(Alignment.Center) + .offset(x = designDp(-1.5f), y = designDp(-30f)) + .size(designDp(188f)) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + } + ) { + drawCircle(Color.White.copy(alpha = .055f)) + drawCircle(Color.White.copy(alpha = .16f), style = Stroke(width = 1.dp.toPx())) + } + } +} + +@Composable +private fun FigmaRing( + timeMs: Float, + designDp: (Float) -> Dp, + delayMs: Float, + opacityPeak: Float, + opacityInEnd: Float, + opacityOutStart: Float, + opacityOutEnd: Float, + scalePeak: Float, + scalePeakTime: Float, + scaleExit: Float +) { + val alpha = sampleTrack( + timeMs, + listOf( + MotionKey(0f, 0f), + MotionKey(delayMs, 0f), + MotionKey(opacityInEnd, opacityPeak, FastOutSlowInEasing), + MotionKey(opacityOutEnd, 0f, FastOutLinearInEasing) + ) + ) + val scale = sampleTrack( + timeMs, + listOf( + MotionKey(0f, .28f), + MotionKey(delayMs, .28f), + MotionKey(scalePeakTime, scalePeak, FastOutSlowInEasing), + MotionKey(2_420f, scalePeak), + MotionKey(2_980f, scaleExit, FastOutSlowInEasing) + ) + ) + Box(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .align(Alignment.Center) + .offset(x = designDp(-1.5f), y = designDp(-30f)) + .size(designDp(188f)) + .graphicsLayer { + this.alpha = alpha.coerceIn(0f, 1f) + scaleX = scale + scaleY = scale + } + ) { + drawCircle( + color = Color.White.copy(alpha = .18f), + style = Stroke(width = 1.dp.toPx()) + ) + } + } +} + +@Composable +private fun FigmaSportIcon( + timeMs: Float, + motion: SportMotion, + designDp: (Float) -> Dp +) { + val peakTime = motion.enterAtMs + 160f + val bounceTime = motion.enterAtMs + 310f + val settleTime = motion.enterAtMs + 520f + val landedTime = motion.enterAtMs + 700f + val wiggleTime = motion.enterAtMs + 860f + val settledTime = motion.enterAtMs + 1_020f + val centerX = motion.left + 18f - 196.5f + val centerY = motion.top + 18f - 426f + val radius = kotlin.math.hypot(centerX, centerY).coerceAtLeast(1f) + val entryX = centerX / radius * 450f + val entryY = centerY / radius * 450f + val exitX = centerX / radius * 58f + val exitY = centerY / radius * 58f + + val alpha = sampleTrack( + timeMs, + listOf( + MotionKey(0f, 0f), + MotionKey(motion.enterAtMs, 0f), + MotionKey(peakTime, motion.peakAlpha), + MotionKey(ICONS_HOLD_END_MS.toFloat(), motion.peakAlpha), + MotionKey(ICONS_FADE_END_MS.toFloat(), 0f, FastOutLinearInEasing) + ) + ) + val x = sampleTrack( + timeMs, + listOf( + MotionKey(0f, entryX), + MotionKey(motion.enterAtMs, entryX), + MotionKey(bounceTime, motion.bounceX, FastOutSlowInEasing), + MotionKey(settleTime, motion.settleX, FastOutSlowInEasing), + MotionKey(landedTime, 0f, FastOutSlowInEasing), + MotionKey(ICONS_HOLD_END_MS.toFloat(), 0f), + MotionKey(ICONS_EXIT_END_MS.toFloat(), exitX, FastOutLinearInEasing) + ) + ) + val y = sampleTrack( + timeMs, + listOf( + MotionKey(0f, entryY), + MotionKey(motion.enterAtMs, entryY), + MotionKey(bounceTime, motion.bounceY, FastOutSlowInEasing), + MotionKey(settleTime, motion.settleY, FastOutSlowInEasing), + MotionKey(landedTime, 0f, FastOutSlowInEasing), + MotionKey(wiggleTime, -5f, FastOutSlowInEasing), + MotionKey(settledTime, 0f, FastOutSlowInEasing), + MotionKey(ICONS_HOLD_END_MS.toFloat(), 0f), + MotionKey(ICONS_EXIT_END_MS.toFloat(), exitY, FastOutLinearInEasing) + ) + ) + val scale = sampleTrack( + timeMs, + listOf( + MotionKey(0f, .45f), + MotionKey(motion.enterAtMs, .45f), + MotionKey(bounceTime, 1.13f, FastOutSlowInEasing), + MotionKey(settleTime, .94f, FastOutSlowInEasing), + MotionKey(landedTime, 1f, FastOutSlowInEasing), + MotionKey(wiggleTime, 1.04f, FastOutSlowInEasing), + MotionKey(settledTime, 1f, FastOutSlowInEasing), + MotionKey(ICONS_HOLD_END_MS.toFloat(), 1f), + MotionKey(ICONS_EXIT_END_MS.toFloat(), .68f, FastOutLinearInEasing) + ) + ) + val rotation = sampleTrack( + timeMs, + listOf( + MotionKey(0f, motion.initialRotation), + MotionKey(motion.enterAtMs, motion.initialRotation), + MotionKey(bounceTime, motion.initialRotation * -.5f, FastOutSlowInEasing), + MotionKey(settleTime, motion.initialRotation * .22f, FastOutSlowInEasing), + MotionKey(landedTime, 0f, FastOutSlowInEasing), + MotionKey(wiggleTime, motion.initialRotation * .22f, FastOutSlowInEasing), + MotionKey(settledTime, 0f, FastOutSlowInEasing), + MotionKey(ICONS_HOLD_END_MS.toFloat(), 0f), + MotionKey(ICONS_EXIT_END_MS.toFloat(), motion.initialRotation * .55f, FastOutLinearInEasing) + ) + ) + + Box( + modifier = Modifier + .offset(x = designDp(motion.left), y = designDp(motion.top)) + .size(designDp(36f)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(motion.drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier + .size(36.dp) + .graphicsLayer { + this.alpha = alpha.coerceIn(0f, 1f) + scaleX = scale + scaleY = scale + rotationZ = rotation + translationX = designDp(x).toPx() + translationY = designDp(y).toPx() + } + ) + } +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun OnboardingScreenPreview() { + OnboardingScreen(onSignUp = {}, onLogIn = {}, onLater = {}) +} diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt deleted file mode 100644 index a00f500..0000000 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt.kt +++ /dev/null @@ -1,645 +0,0 @@ -package com.yourpackage.ui.onboarding - -import androidx.annotation.DrawableRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.keyframes -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -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.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Text -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.platform.LocalDensity -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.sp -import com.cornellappdev.score.R -import com.cornellappdev.score.components.OnboardingButtons -import com.cornellappdev.score.theme.CrimsonPrimary -import kotlinx.coroutines.delay -import kotlin.math.cos -import kotlin.math.roundToInt -import kotlin.math.sin - -private enum class OnboardingAnimationPhase { - Initial, - Icons, - IconsFading, - SpeakerTransition, - Complete -} - -@Composable -fun OnboardingScreen( - onSignUp: () -> Unit, - onLogIn: () -> Unit, - onLater: () -> Unit, - modifier: Modifier = Modifier -) { - var showIcons by remember { mutableStateOf(false) } - var showRings by remember { mutableStateOf(true) } - var showButtons by remember { mutableStateOf(false) } - var moveSpeakerUp by remember { mutableStateOf(false) } - - /* - * Timeline based on the Figma animation: - * - * 0ms -> red background - * ~300ms -> megaphone/rings start - * ~400ms -> sports icons start appearing - * ~2500ms -> icons finish - * ~3000ms -> icons fade out - * ~3200ms -> megaphone moves upward - * ~3400ms -> buttons appear - */ - LaunchedEffect(Unit) { - delay(350) - showIcons = true - - delay(2550) - showIcons = false - showRings = false - - delay(1300) - moveSpeakerUp = true - - delay(700) - showButtons = true - } - - Box( - modifier = modifier - .fillMaxSize() - .background( - Brush.radialGradient( - colors = listOf( - Color(0xFFB52A12), - CrimsonPrimary, - Color(0xFF941B0A) - ) - ) - ) - ) { - - // Sports icons spread outward and fade as the megaphone grows. - SportsIconAnimation( - modifier = Modifier.fillMaxSize(), - visible = showIcons - ) - - // Speaker + expanding circles - Speaker( - moveUp = moveSpeakerUp, - showRings = showRings, - modifier = Modifier.align(Alignment.Center) - ) - - if (showButtons) { - OnboardingButtons( - onSignUp = onSignUp, - onLogIn = onLogIn, - onLater = onLater, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding( - start = 24.dp, - end = 24.dp, - bottom = 48.dp - ) - ) - } - } -} - -@Composable -fun Speaker( - moveUp: Boolean, - modifier: Modifier = Modifier, - showRings: Boolean = true -) { - val transition = updateTransition( - targetState = moveUp, - label = "speakerTransition" - ) - - val verticalOffset by transition.animateFloat( - transitionSpec = { - tween( - durationMillis = 700, - easing = FastOutSlowInEasing - ) - }, - label = "speakerY" - ) { isUp -> - if (isUp) -120f else 0f - } - - val scale by transition.animateFloat( - transitionSpec = { - tween( - durationMillis = 700, - easing = FastOutSlowInEasing - ) - }, - label = "speakerScale" - ) { isUp -> - if (isUp) 1.45f else 1f // this is centered - } - - val iconSize by transition.animateDp( - transitionSpec = { - tween( - durationMillis = 700, - easing = FastOutSlowInEasing - ) - }, - label = "speakerIconSize" - ) { isUp -> - if (isUp) 136.dp else 132.dp - } - - Box( - modifier = modifier - .offset { - IntOffset( - x = 0, - y = verticalOffset.roundToInt() - ) - } - .scale(scale) - .size(190.dp), - contentAlignment = Alignment.Center - ) { - - // Expanding circles - val infiniteTransition = rememberInfiniteTransition( - label = "rings" - ) - - val scale1 by infiniteTransition.animateFloat( - initialValue = 0.75f, - targetValue = 1.25f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1800, - easing = LinearOutSlowInEasing - ), - repeatMode = RepeatMode.Restart - ), - label = "ring1" - ) - - val scale2 by infiniteTransition.animateFloat( - initialValue = 0.65f, - targetValue = 1.15f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1800, - delayMillis = 450, - easing = LinearOutSlowInEasing - ), - repeatMode = RepeatMode.Restart - ), - label = "ring2" - ) - - val scale3 by infiniteTransition.animateFloat( - initialValue = 0.55f, - targetValue = 1.05f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1800, - delayMillis = 900, - easing = LinearOutSlowInEasing - ), - repeatMode = RepeatMode.Restart - ), - label = "ring3" - ) - - AnimatedVisibility( - visible = showRings, - enter = fadeIn(tween(250)), - exit = fadeOut(tween(700)) - ) { - Box(contentAlignment = Alignment.Center) { - Ring( - scale = scale1, - modifier = Modifier.size(190.dp) - ) - - Ring( - scale = scale2, - modifier = Modifier.size(190.dp) - ) - - Ring( - scale = scale3, - modifier = Modifier.size(190.dp) - ) - } - } - - Image( - painter = painterResource(R.drawable.ic_speaker), - contentDescription = null, - modifier = Modifier.size(iconSize), - colorFilter = ColorFilter.tint(Color.White) - ) - } -} - -@Composable -private fun Ring( - scale: Float, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .scale(scale) - .clip(shape = CircleShape) - .background(color = Color.White.copy(alpha = 0.055f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.15f), - shape = CircleShape - ) - ) -} - -private data class SportsIcon( - @DrawableRes val drawable: Int, - val x: Float, - val y: Float, - val delay: Int, - val rotation: Float = 0f -) - -private val sportsIcons = listOf( - // Top row - SportsIcon( - drawable = R.drawable.ic_rowing_lightweight, - x = -0.22f, - y = -0.43f, - delay = 0 - ), - SportsIcon( - drawable = R.drawable.ic_field_hockey, - x = 0.10f, - y = -0.47f, - delay = 100 - ), - SportsIcon( - drawable = R.drawable.ic_wrestling, - x = 0.38f, - y = -0.40f, - delay = 200 - ), - - // Upper-middle - SportsIcon( - drawable = R.drawable.ic_basketball, - x = -0.03f, - y = -0.29f, - delay = 250 - ), - SportsIcon( - drawable = R.drawable.ic_tennis, - x = 0.27f, - y = -0.27f, - delay = 350 - ), - SportsIcon( - drawable = R.drawable.ic_volleyball, - x = -0.40f, - y = -0.22f, - delay = 400 - ), - SportsIcon( - drawable = R.drawable.ic_soccer, - x = 0.47f, - y = -0.17f, - delay = 500 - ), - - // Middle - SportsIcon( - drawable = R.drawable.ic_track_field, - x = -0.48f, - y = 0.02f, - delay = 550 - ), - SportsIcon( - drawable = R.drawable.ic_lacrosse, - x = 0.48f, - y = 0.05f, - delay = 650 - ), - - // Lower-middle - SportsIcon( - drawable = R.drawable.ic_swim_dive, - x = -0.40f, - y = 0.22f, - delay = 700 - ), - SportsIcon( - drawable = R.drawable.ic_field_hockey, - x = 0.23f, - y = 0.22f, - delay = 800 - ), - SportsIcon( - drawable = R.drawable.ic_baseball, - x = 0.43f, - y = 0.27f, - delay = 900 - ), - - // Bottom - SportsIcon( - drawable = R.drawable.ic_tennis, - x = -0.34f, - y = 0.40f, - delay = 1000 - ), - SportsIcon( - drawable = R.drawable.ic_golf, - x = 0.02f, - y = 0.42f, - delay = 1100 - ), - SportsIcon( - drawable = R.drawable.ic_football, - x = 0.35f, - y = 0.43f, - delay = 1200 - ) -) - -@Composable -fun SportsIconAnimation( - modifier: Modifier = Modifier, - visible: Boolean = true -) { - BoxWithConstraints( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - val animationSize = with(LocalDensity.current) { maxWidth.toPx() } - - sportsIcons.forEach { icon -> - AnimatedSportsIcon( - icon = icon, - animationSize = animationSize, - visible = visible - ) - } - } -} - -@Composable -private fun AnimatedSportsIcon( - icon: SportsIcon, - animationSize: Float, - previewProgress: Float? = null, - visible: Boolean = true -) { - var started by remember { mutableStateOf(false) } - - LaunchedEffect(visible) { - if (visible && !started) { - delay(icon.delay.toLong()) - started = true - } - } - - val iconState = when { - !started -> 0 - visible -> 1 - else -> 2 - } - - val transition = updateTransition( - targetState = iconState, - label = "iconTransition" - ) - - val animatedProgress by transition.animateFloat( - transitionSpec = { - if (targetState == 1) { - spring(dampingRatio = 0.55f, stiffness = 500f) - } else { - tween( - durationMillis = if (targetState == 2) 1300 else 900, - easing = FastOutSlowInEasing - ) - } - }, - label = "iconProgress" - ) { state -> - when (state) { - 0 -> 0f - 1 -> 1f - else -> 1.6f - } - } - - val entranceWiggle = if ((icon.delay / 100) % 2 == 0) -18f else 18f - - val animatedScale by transition.animateFloat( - transitionSpec = { - if (targetState == 1) { - spring(dampingRatio = 0.45f, stiffness = 600f) - } else { - tween(durationMillis = 250) - } - }, - label = "iconScale" - ) { state -> - when (state) { - 0 -> 0.35f - else -> 1f - } - } - - val animatedRotation by transition.animateFloat( - transitionSpec = { - if (targetState == 1) { - spring(dampingRatio = 0.4f, stiffness = 500f) - } else { - tween(durationMillis = 250) - } - }, - label = "iconRotation" - ) { state -> - if (state == 0) icon.rotation + entranceWiggle else icon.rotation - } - - val animatedAlpha by transition.animateFloat( - transitionSpec = { - if (targetState == 2) { - keyframes { - durationMillis = 1300 - 1f at 0 - 1f at 350 - 0f at 1300 - } - } else { - tween(durationMillis = 500) - } - }, - label = "iconAlpha" - ) { state -> - if (state == 1) 1f else 0f - } - - val progress = previewProgress ?: animatedProgress - val alpha = if (previewProgress != null) { - if (previewProgress > 0f) 1f else 0f - } else { - animatedAlpha - } - val scale = if (previewProgress != null) { - if (previewProgress > 0f) 1f else 0.35f - } else { - animatedScale - } - val rotation = if (previewProgress != null) icon.rotation else animatedRotation - - val x = icon.x * animationSize * progress - val y = icon.y * 1.15f * animationSize * progress - - Image( - painter = painterResource(icon.drawable), - contentDescription = null, - colorFilter = ColorFilter.tint(Color.White), - modifier = Modifier - .size(28.5.dp) - .alpha(alpha) - .scale(scale) - .rotate(rotation) - .offset { - IntOffset( - x = x.roundToInt(), - y = y.roundToInt() - ) - } - ) -} - -@Preview( - name = "Speaker - Center", - showBackground = true, - backgroundColor = 0xFFA3200C -) -@Composable -private fun SpeakerPreview() { - Box( - modifier = Modifier - .fillMaxSize() - .background(CrimsonPrimary), - contentAlignment = Alignment.Center - ) { - Speaker( - moveUp = false - ) - } -} - -@Preview( - name = "Speaker - Moved Up", - showBackground = true, - backgroundColor = 0xFFA3200C -) -@Composable -private fun SpeakerMovedPreview() { - Box( - modifier = Modifier - .fillMaxSize() - .background(CrimsonPrimary), - contentAlignment = Alignment.Center - ) { - Speaker( - moveUp = true - ) - } -} - -@Preview( - name = "All Sports Icons", - showBackground = true, - backgroundColor = 0xFFA3200C, - widthDp = 394, - heightDp = 852 -) -@Composable -private fun SportsIconAnimationPreview() { - Box( - modifier = Modifier - .fillMaxSize() - .background(CrimsonPrimary), - contentAlignment = Alignment.Center - ) { - sportsIcons.forEach { icon -> - AnimatedSportsIcon( - icon = icon, - 1f - ) - } - - Image( - painter = painterResource(R.drawable.ic_speaker), - contentDescription = null, - modifier = Modifier.size(132.dp), - colorFilter = ColorFilter.tint(Color.White) - ) - } -} \ No newline at end of file From 571bcac90c79e0fbd43d7265020e4f5e934376c1 Mon Sep 17 00:00:00 2001 From: amjiao Date: Fri, 16 Oct 2026 13:32:52 -0400 Subject: [PATCH 3/7] Complete onboarding animation --- .../com/cornellappdev/score/MainActivity.kt | 43 ++++++++++++++++--- .../score/components/OnboardingButtons.kt | 24 +++++++---- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/score/MainActivity.kt b/app/src/main/java/com/cornellappdev/score/MainActivity.kt index a9b44ff..512793e 100644 --- a/app/src/main/java/com/cornellappdev/score/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/score/MainActivity.kt @@ -4,13 +4,21 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.cornellappdev.score.nav.root.RootNavigation -import com.cornellappdev.score.screen.OnboardingAnimationPreviewScreen +import com.cornellappdev.score.screen.OnboardingScreen import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics import dagger.hilt.android.AndroidEntryPoint +private const val APP_PREFERENCES = "score_app_preferences" +private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" + private lateinit var firebaseAnalytics: FirebaseAnalytics @AndroidEntryPoint @@ -18,14 +26,35 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + val preferences = getSharedPreferences(APP_PREFERENCES, MODE_PRIVATE) + val onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false) + setContent { - //RootNavigation() - OnboardingAnimationPreviewScreen( - onSignUp = {}, - onLogIn = {}, - onLater = {} - ) + var showOnboarding by rememberSaveable { + mutableStateOf(!onboardingCompleted) + } + + val completeOnboarding = remember { + { + preferences.edit() + .putBoolean(KEY_ONBOARDING_COMPLETED, true) + .apply() + showOnboarding = false + } + } + + if (showOnboarding) { + OnboardingScreen( + onSignUp = completeOnboarding, + onLogIn = completeOnboarding, + onLater = completeOnboarding + ) + } else { + RootNavigation() + } } + firebaseAnalytics = Firebase.analytics } } diff --git a/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt index a03f591..97ec48c 100644 --- a/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt +++ b/app/src/main/java/com/cornellappdev/score/components/OnboardingButtons.kt @@ -1,9 +1,9 @@ package com.cornellappdev.score.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically -import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,10 +25,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.cornellappdev.score.theme.CrimsonPrimary import com.cornellappdev.score.theme.Style.heading3 import com.cornellappdev.score.theme.Style.labelsNormal @@ -83,7 +81,9 @@ private fun OnboardingButtonsContent( horizontalAlignment = Alignment.CenterHorizontally ) { OnboardingButtonSlot( - modifier = Modifier.fillMaxWidth().height(50.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), visible = showSignUp, animateEntrance = animateEntrance, initialOffset = 26.dp, @@ -91,7 +91,9 @@ private fun OnboardingButtonsContent( ) { Button( onClick = onSignUp, - modifier = Modifier.fillMaxWidth().height(48.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), shape = RoundedCornerShape(100), colors = ButtonDefaults.buttonColors( containerColor = Color.White, @@ -109,7 +111,9 @@ private fun OnboardingButtonsContent( Spacer(modifier = Modifier.height(16.dp)) OnboardingButtonSlot( - modifier = Modifier.fillMaxWidth().height(48.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), visible = showLogIn, animateEntrance = animateEntrance, initialOffset = 24.dp, @@ -117,7 +121,9 @@ private fun OnboardingButtonsContent( ) { OutlinedButton( onClick = onLogIn, - modifier = Modifier.fillMaxWidth().height(48.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), shape = RoundedCornerShape(100), border = BorderStroke( width = 1.dp, @@ -138,7 +144,9 @@ private fun OnboardingButtonsContent( Spacer(modifier = Modifier.height(24.dp)) OnboardingButtonSlot( - modifier = Modifier.fillMaxWidth().height(40.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp), visible = showLater, animateEntrance = animateEntrance, initialOffset = 18.dp, From cdfaf5e7f3882ec959d08b86a650488b822a2254 Mon Sep 17 00:00:00 2001 From: amjiao Date: Fri, 16 Oct 2026 13:45:21 -0400 Subject: [PATCH 4/7] remove onboarding preview screen from commit --- .../OnboardingAnimationPreviewScreen.kt | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt deleted file mode 100644 index a2505d8..0000000 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingAnimationPreviewScreen.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.cornellappdev.score.screen - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -/** - * Preview host for replaying the same onboarding animation used by the real screen. - */ -@Composable -fun OnboardingAnimationPreviewScreen( - onSignUp: () -> Unit, - onLogIn: () -> Unit, - onLater: () -> Unit, - modifier: Modifier = Modifier -) { - var animationKey by remember { mutableIntStateOf(0) } - - Box(modifier = modifier.fillMaxSize()) { - OnboardingScreen( - onSignUp = onSignUp, - onLogIn = onLogIn, - onLater = onLater, - animationKey = animationKey - ) - - Button( - onClick = { animationKey++ }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 56.dp, end = 16.dp) - ) { - Text("Replay") - } - } -} From 2e6d25be08e9e96a99911cd38cd9be56e6f2aa1b Mon Sep 17 00:00:00 2001 From: amjiao Date: Fri, 16 Oct 2026 13:55:43 -0400 Subject: [PATCH 5/7] Clean up OnboardingScreen.kt --- .../score/screen/OnboardingScreen.kt | 356 ++++++++++++++++-- 1 file changed, 319 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt index eb5f254..aeaba8c 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt @@ -70,27 +70,300 @@ private data class SportMotion( ) private val sportMotions = listOf( - SportMotion(R.drawable.ic_basketball, 172f, 237f, 340f, .94f, 5f, 141f, 13.441f, -16.006f, -6.071f, 6.568f, -18f), - SportMotion(R.drawable.ic_baseball, 285f, 299f, 385f, .94f, -108f, 79f, 3.615f, -19.99f, -1.141f, 8.64f, 18f), - SportMotion(R.drawable.ic_cross_country, 20f, 331f, 430f, .94f, 157f, 47f, -13.255f, -18.582f, 5.258f, 8.15f, -18f), - SportMotion(R.drawable.ic_football, 316f, 441f, 475f, .94f, -139f, -63f, 21.069f, -5.821f, -8.856f, 2.903f, 18f), - SportMotion(R.drawable.ic_golf, 176f, 526f, 520f, .94f, 1f, -148f, -14.11f, 16.185f, 6.345f, -6.617f, -18f), - SportMotion(R.drawable.ic_volleyball, 20f, 482f, 565f, .94f, 157f, -104f, -9.539f, 23.112f, 3.586f, -9.932f, 18f), - SportMotion(R.drawable.ic_ice_hockey, 220f, 179f, 580f, .8f, -43f, 199f, 18.414f, -18.933f, -8.093f, 7.624f, -18f), - SportMotion(R.drawable.ic_swim_dive, 259f, 500f, 610f, .94f, -82f, -122f, -2.599f, 21.23f, 1.539f, -9.004f, -18f), - SportMotion(R.drawable.ic_soccer, 251f, 240f, 625f, .8f, -74f, 138f, -4.198f, -21.796f, 2.222f, 9.187f, 18f), - SportMotion(R.drawable.ic_tennis, 334f, 526f, 670f, .8f, -157f, -148f, 7.667f, 26.467f, -2.744f, -11.244f, -18f), - SportMotion(R.drawable.ic_lacrosse, 255f, 582f, 715f, .8f, -78f, -204f, 21.657f, 17.44f, -9.395f, -6.93f, 18f), - SportMotion(R.drawable.ic_wrestling, 94f, 508f, 760f, .8f, 83f, -130f, -20.93f, 6.766f, 9.045f, -2.46f, -18f), - SportMotion(R.drawable.ic_fencing, 23f, 223f, 805f, .8f, 154f, 155f, -26.871f, -7.183f, 11.399f, 2.535f, 18f), - SportMotion(R.drawable.ic_softball, 41f, 575f, 820f, .66f, 136f, -197f, -26.481f, 13.716f, 11.305f, -5.286f, -18f), - SportMotion(R.drawable.ic_sailing, 72f, 263f, 850f, .8f, 105f, 115f, -1.211f, -22.09f, .073f, 9.423f, -18f), - SportMotion(R.drawable.ic_equestrian, 142f, 591f, 865f, .66f, 35f, -213f, 9.965f, 25.7f, -4.642f, -10.607f, 18f), - SportMotion(R.drawable.ic_field_hockey, 334f, 355f, 910f, .66f, -157f, 23f, 19.299f, 11.322f, -7.978f, -5.198f, -18f), - SportMotion(R.drawable.ic_gymnastics, 338f, 257f, 955f, .66f, -161f, 121f, 9.299f, -24.502f, -3.46f, 10.481f, 18f), - SportMotion(R.drawable.ic_squash, 61f, 415f, 1_000f, .66f, 116f, -37f, -17.014f, -9.268f, 7.134f, 4.337f, -18f), - SportMotion(R.drawable.ic_rowing_lightweight, 101f, 191f, 1_045f, .66f, 76f, 187f, -21.33f, -15.299f, 9.256f, 6.043f, 18f), - SportMotion(R.drawable.ic_track_field, 302f, 201f, 1_090f, .66f, -125f, 177f, 25.186f, -11.394f, -10.771f, 4.331f, -18f) + SportMotion( + R.drawable.ic_basketball, + 172f, + 237f, + 340f, + .94f, + 5f, + 141f, + 13.441f, + -16.006f, + -6.071f, + 6.568f, + -18f + ), + SportMotion( + R.drawable.ic_baseball, + 285f, + 299f, + 385f, + .94f, + -108f, + 79f, + 3.615f, + -19.99f, + -1.141f, + 8.64f, + 18f + ), + SportMotion( + R.drawable.ic_cross_country, + 20f, + 331f, + 430f, + .94f, + 157f, + 47f, + -13.255f, + -18.582f, + 5.258f, + 8.15f, + -18f + ), + SportMotion( + R.drawable.ic_football, + 316f, + 441f, + 475f, + .94f, + -139f, + -63f, + 21.069f, + -5.821f, + -8.856f, + 2.903f, + 18f + ), + SportMotion( + R.drawable.ic_golf, + 176f, + 526f, + 520f, + .94f, + 1f, + -148f, + -14.11f, + 16.185f, + 6.345f, + -6.617f, + -18f + ), + SportMotion( + R.drawable.ic_volleyball, + 20f, + 482f, + 565f, + .94f, + 157f, + -104f, + -9.539f, + 23.112f, + 3.586f, + -9.932f, + 18f + ), + SportMotion( + R.drawable.ic_ice_hockey, + 220f, + 179f, + 580f, + .8f, + -43f, + 199f, + 18.414f, + -18.933f, + -8.093f, + 7.624f, + -18f + ), + SportMotion( + R.drawable.ic_swim_dive, + 259f, + 500f, + 610f, + .94f, + -82f, + -122f, + -2.599f, + 21.23f, + 1.539f, + -9.004f, + -18f + ), + SportMotion( + R.drawable.ic_soccer, + 251f, + 240f, + 625f, + .8f, + -74f, + 138f, + -4.198f, + -21.796f, + 2.222f, + 9.187f, + 18f + ), + SportMotion( + R.drawable.ic_tennis, + 334f, + 526f, + 670f, + .8f, + -157f, + -148f, + 7.667f, + 26.467f, + -2.744f, + -11.244f, + -18f + ), + SportMotion( + R.drawable.ic_lacrosse, + 255f, + 582f, + 715f, + .8f, + -78f, + -204f, + 21.657f, + 17.44f, + -9.395f, + -6.93f, + 18f + ), + SportMotion( + R.drawable.ic_wrestling, + 94f, + 508f, + 760f, + .8f, + 83f, + -130f, + -20.93f, + 6.766f, + 9.045f, + -2.46f, + -18f + ), + SportMotion( + R.drawable.ic_fencing, + 23f, + 223f, + 805f, + .8f, + 154f, + 155f, + -26.871f, + -7.183f, + 11.399f, + 2.535f, + 18f + ), + SportMotion( + R.drawable.ic_softball, + 41f, + 575f, + 820f, + .66f, + 136f, + -197f, + -26.481f, + 13.716f, + 11.305f, + -5.286f, + -18f + ), + SportMotion( + R.drawable.ic_sailing, + 72f, + 263f, + 850f, + .8f, + 105f, + 115f, + -1.211f, + -22.09f, + .073f, + 9.423f, + -18f + ), + SportMotion( + R.drawable.ic_equestrian, + 142f, + 591f, + 865f, + .66f, + 35f, + -213f, + 9.965f, + 25.7f, + -4.642f, + -10.607f, + 18f + ), + SportMotion( + R.drawable.ic_field_hockey, + 334f, + 355f, + 910f, + .66f, + -157f, + 23f, + 19.299f, + 11.322f, + -7.978f, + -5.198f, + -18f + ), + SportMotion( + R.drawable.ic_gymnastics, + 338f, + 257f, + 955f, + .66f, + -161f, + 121f, + 9.299f, + -24.502f, + -3.46f, + 10.481f, + 18f + ), + SportMotion( + R.drawable.ic_squash, + 61f, + 415f, + 1_000f, + .66f, + 116f, + -37f, + -17.014f, + -9.268f, + 7.134f, + 4.337f, + -18f + ), + SportMotion( + R.drawable.ic_rowing_lightweight, + 101f, + 191f, + 1_045f, + .66f, + 76f, + 187f, + -21.33f, + -15.299f, + 9.256f, + 6.043f, + 18f + ), + SportMotion( + R.drawable.ic_track_field, + 302f, + 201f, + 1_090f, + .66f, + -125f, + 177f, + 25.186f, + -11.394f, + -10.771f, + 4.331f, + -18f + ) ) @Composable @@ -101,25 +374,26 @@ fun OnboardingScreen( modifier: Modifier = Modifier, animationKey: Int = 0 ) { - val playhead = remember(animationKey) { Animatable(0f) } + val animationTimeMs = remember(animationKey) { Animatable(0f) } LaunchedEffect(animationKey) { - playhead.snapTo(0f) - playhead.animateTo( + animationTimeMs.snapTo(0f) + animationTimeMs.animateTo( TIMELINE_DURATION_MS.toFloat(), animationSpec = tween(TIMELINE_DURATION_MS, easing = LinearEasing) ) } - val timeMs = playhead.value + val timeMs = animationTimeMs.value BoxWithConstraints( modifier = modifier .fillMaxSize() .background(CrimsonPrimary) ) { + /*designScale and designDp scale from Figma units to Compose units*/ val designScale = min(maxWidth.value / 393f, maxHeight.value / 852f) val designDp: (Float) -> Dp = { (it * designScale).dp } - FigmaSpeaker( + AnimatedSpeaker( timeMs = timeMs, left = 98f, top = 229f, @@ -127,7 +401,11 @@ fun OnboardingScreen( designDp = designDp, alphaKeys = listOf(MotionKey(0f, 0f), MotionKey(2_760f, 0f), MotionKey(3_280f, 1f)), scaleKeys = listOf(MotionKey(0f, .67f), MotionKey(2_760f, .67f), MotionKey(3_280f, 1f)), - yKeys = listOf(MotionKey(0f, 68.5f), MotionKey(2_760f, 68.5f), MotionKey(3_280f, 0f, FastOutSlowInEasing)) + yKeys = listOf( + MotionKey(0f, 68.5f), + MotionKey(2_760f, 68.5f), + MotionKey(3_280f, 0f, FastOutSlowInEasing) + ) ) if (timeMs >= 3_020f) { @@ -141,11 +419,11 @@ fun OnboardingScreen( ) } - FigmaHalo( + AnimatedHalo( timeMs = timeMs, designDp = designDp ) - FigmaRing( + AnimatedRing( timeMs = timeMs, designDp = designDp, delayMs = 240f, @@ -157,7 +435,7 @@ fun OnboardingScreen( scalePeakTime = 1_020f, scaleExit = 2.092f ) - FigmaRing( + AnimatedRing( timeMs = timeMs, designDp = designDp, delayMs = 420f, @@ -170,7 +448,7 @@ fun OnboardingScreen( scaleExit = 2.322f ) - FigmaSpeaker( + AnimatedSpeaker( timeMs = timeMs, left = 129f, top = 330f, @@ -207,13 +485,13 @@ fun OnboardingScreen( ) sportMotions.forEach { sport -> - FigmaSportIcon(timeMs = timeMs, motion = sport, designDp = designDp) + AnimatedSportIcon(timeMs = timeMs, motion = sport, designDp = designDp) } } } @Composable -private fun FigmaSpeaker( +private fun AnimatedSpeaker( timeMs: Float, left: Float, top: Float, @@ -252,7 +530,7 @@ private fun FigmaSpeaker( } @Composable -private fun FigmaHalo(timeMs: Float, designDp: (Float) -> Dp) { +private fun AnimatedHalo(timeMs: Float, designDp: (Float) -> Dp) { val alpha = sampleTrack( timeMs, listOf( @@ -292,7 +570,7 @@ private fun FigmaHalo(timeMs: Float, designDp: (Float) -> Dp) { } @Composable -private fun FigmaRing( +private fun AnimatedRing( timeMs: Float, designDp: (Float) -> Dp, delayMs: Float, @@ -344,7 +622,7 @@ private fun FigmaRing( } @Composable -private fun FigmaSportIcon( +private fun AnimatedSportIcon( timeMs: Float, motion: SportMotion, designDp: (Float) -> Dp @@ -424,7 +702,11 @@ private fun FigmaSportIcon( MotionKey(wiggleTime, motion.initialRotation * .22f, FastOutSlowInEasing), MotionKey(settledTime, 0f, FastOutSlowInEasing), MotionKey(ICONS_HOLD_END_MS.toFloat(), 0f), - MotionKey(ICONS_EXIT_END_MS.toFloat(), motion.initialRotation * .55f, FastOutLinearInEasing) + MotionKey( + ICONS_EXIT_END_MS.toFloat(), + motion.initialRotation * .55f, + FastOutLinearInEasing + ) ) ) From 5ff098dec548168811aef9c9e514455758742978 Mon Sep 17 00:00:00 2001 From: amjiao Date: Wed, 21 Oct 2026 18:31:07 -0400 Subject: [PATCH 6/7] fix nav padding per coderabbit comment --- .../java/com/cornellappdev/score/screen/OnboardingScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt index aeaba8c..368a0f6 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt @@ -12,10 +12,13 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -415,6 +418,7 @@ fun OnboardingScreen( onLater = onLater, modifier = Modifier .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) .padding(start = 24.dp, end = 24.dp, bottom = 40.dp) ) } From f37966b4647a1f1889d00145eabf80c1760e7070 Mon Sep 17 00:00:00 2001 From: amjiao Date: Wed, 21 Oct 2026 18:47:34 -0400 Subject: [PATCH 7/7] make common onboarding screen origin per coderabbit review --- .../score/screen/OnboardingScreen.kt | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt index 368a0f6..b92aa85 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/OnboardingScreen.kt @@ -714,27 +714,33 @@ private fun AnimatedSportIcon( ) ) - Box( - modifier = Modifier - .offset(x = designDp(motion.left), y = designDp(motion.top)) - .size(designDp(36f)), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(motion.drawable), - contentDescription = null, - colorFilter = ColorFilter.tint(Color.White), + Box(modifier = Modifier.fillMaxSize()) { + Box( modifier = Modifier - .size(36.dp) - .graphicsLayer { - this.alpha = alpha.coerceIn(0f, 1f) - scaleX = scale - scaleY = scale - rotationZ = rotation - translationX = designDp(x).toPx() - translationY = designDp(y).toPx() - } - ) + .align(Alignment.Center) + .offset( + x = designDp(motion.left + 18f - 196.5f), + y = designDp(motion.top + 18f - 426f) + ) + .size(designDp(36f)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(motion.drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier + .size(designDp(36f)) + .graphicsLayer { + this.alpha = alpha.coerceIn(0f, 1f) + scaleX = scale + scaleY = scale + rotationZ = rotation + translationX = designDp(x).toPx() + translationY = designDp(y).toPx() + } + ) + } } }