diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8269ddd..623c2b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.ui.graphics) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/cornellappdev/chimes/MainActivity.kt b/app/src/main/java/com/cornellappdev/chimes/MainActivity.kt index 867e082..ffb05e4 100644 --- a/app/src/main/java/com/cornellappdev/chimes/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/chimes/MainActivity.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.sp import com.cornellappdev.chimes.ui.components.HeaderButton import com.cornellappdev.chimes.ui.navigation.MainNavigation import com.cornellappdev.chimes.ui.screens.HomeScreen +import com.cornellappdev.chimes.ui.screens.Onboarding import com.cornellappdev.chimes.ui.theme.ChimesandroidTheme class MainActivity : ComponentActivity() { diff --git a/app/src/main/java/com/cornellappdev/chimes/ui/navigation/MainNavigation.kt b/app/src/main/java/com/cornellappdev/chimes/ui/navigation/MainNavigation.kt index 56761da..9476a52 100644 --- a/app/src/main/java/com/cornellappdev/chimes/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/cornellappdev/chimes/ui/navigation/MainNavigation.kt @@ -8,11 +8,12 @@ import androidx.compose.runtime.remember import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.cornellappdev.chimes.ui.screens.HomeScreen +import com.cornellappdev.chimes.ui.screens.Onboarding @RequiresApi(Build.VERSION_CODES.S) @Composable -fun MainNavigation () { - val backStack = remember { mutableStateListOf(NavigationItem.Home) } +fun MainNavigation() { + val backStack = remember { mutableStateListOf(NavigationItem.Onboarding) } NavDisplay( backStack = backStack, @@ -20,9 +21,18 @@ fun MainNavigation () { if (backStack.size > 1) backStack.removeLastOrNull() }, entryProvider = entryProvider { + entry { + Onboarding( + onLoginClick = { + backStack.clear() + backStack.add(NavigationItem.Home) + } + ) + } + entry { HomeScreen() } } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/chimes/ui/navigation/NavigationItem.kt b/app/src/main/java/com/cornellappdev/chimes/ui/navigation/NavigationItem.kt index a275b79..16df429 100644 --- a/app/src/main/java/com/cornellappdev/chimes/ui/navigation/NavigationItem.kt +++ b/app/src/main/java/com/cornellappdev/chimes/ui/navigation/NavigationItem.kt @@ -3,9 +3,13 @@ package com.cornellappdev.chimes.ui.navigation sealed class NavigationItem( val route: String, ) { - object Home : NavigationItem ( + object Home : NavigationItem( route = Routes.HOME.name, ) + + object Onboarding : NavigationItem( + route = Routes.ONBOARDING.name, + ) } /** @@ -23,4 +27,4 @@ interface NavUnit { enum class Routes(override var route: String) : NavUnit { HOME("home"), ONBOARDING("onboarding"), -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/chimes/ui/screens/Onboarding.kt b/app/src/main/java/com/cornellappdev/chimes/ui/screens/Onboarding.kt new file mode 100644 index 0000000..673fe43 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/chimes/ui/screens/Onboarding.kt @@ -0,0 +1,336 @@ +package com.cornellappdev.chimes.ui.screens + +import android.annotation.SuppressLint +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cornellappdev.chimes.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val ChimesRed = Color(0xFFCC5555) +private val VeryLightPink = Color(0xFFFEF7F7) +private val VeryLightGray = Color(0xFFBCB2B2) + +@Composable +fun Onboarding( + onLoginClick: () -> Unit = {} +) { + val bgAlpha = remember { Animatable(1f) } + val whiteAlpha = remember { Animatable(0f) } + val logoAlpha = remember { Animatable(0f) } + val logoScale = remember { Animatable(1f) } + val chimesAlpha = remember { Animatable(0f) } + val headerOffsetY = remember { Animatable(0f) } + val loginAlpha = remember { Animatable(0f) } + val loginOffsetY = remember { Animatable(300f) } // Starts 300dp below + var handsSpinStarted by remember { mutableStateOf(false) } + val minuteHandRotation: Float + val hourHandRotation: Float + if (handsSpinStarted) { + val handsRotationTransition = rememberInfiniteTransition(label = "handsRotationTransition") + val minuteRotation by handsRotationTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2_000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "minuteHandRotation" + ) + val hourRotation by handsRotationTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 10_000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "hourHandRotation" + ) + minuteHandRotation = minuteRotation + hourHandRotation = hourRotation + } else { + minuteHandRotation = 0f + hourHandRotation = 0f + } + + LaunchedEffect(Unit) { + delay(300) + + bgAlpha.animateTo(0f, tween(300, easing = LinearEasing)) + whiteAlpha.animateTo(1f, tween(300, easing = LinearEasing)) + logoAlpha.animateTo(1f, tween(300, easing = LinearEasing)) + + delay(300) + + logoScale.animateTo(0.55f, tween(300, easing = FastOutSlowInEasing)) + handsSpinStarted = true + + delay(300) + chimesAlpha.animateTo(1f, tween(300, easing = LinearEasing)) + + delay(300) + + headerOffsetY.animateTo(-100f, tween(300, easing = FastOutSlowInEasing)) + + delay(300) + + launch { + loginOffsetY.animateTo(0f, tween(300, easing = FastOutSlowInEasing)) //slide + } + loginAlpha.animateTo(1f, tween(300, easing = LinearEasing)) //fade + } + + Box(modifier = Modifier.fillMaxSize().background(VeryLightPink)) { + + Image( + painter = painterResource(id = R.drawable.ic_onboarding_background), + contentDescription = "Onboarding background", + modifier = Modifier + .fillMaxSize() + .alpha(bgAlpha.value), + contentScale = ContentScale.Crop + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .offset(y = headerOffsetY.value.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Spacer(modifier = Modifier.weight(1f)) //top half of screen + + ClockLogo( + modifier = Modifier + .size((150 * logoScale.value).dp) + .alpha(logoAlpha.value), + minuteHandRotation = minuteHandRotation, + hourHandRotation = hourHandRotation + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "chimes", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Medium, + color = ChimesRed + ), + modifier = Modifier.alpha(chimesAlpha.value), + fontSize = 40.sp + ) + + // this box takes up the bottom half of the screen, so the login buttons won't push the logo up + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .alpha(loginAlpha.value) + .offset(y = loginOffsetY.value.dp) + .fillMaxWidth() + .padding(horizontal = 15.dp) + ) { + Spacer(modifier = Modifier.height(64.dp)) + + LoginButton( + text = "Log-in with Google", + iconRes = R.drawable.ic_google_g, + contentDescription = "Google logo", + onClick = onLoginClick + ) + + Spacer(modifier = Modifier.height(15.dp)) + + LoginButton( + text = "Log-in with Cornell netID", + iconRes = R.drawable.ic_cornell_logo, + contentDescription = "Cornell logo", + onClick = onLoginClick + ) + + Spacer(modifier = Modifier.height(45.dp)) + + HorizontalDivider( + color = VeryLightGray, + thickness = 1.dp, + modifier = Modifier + .width(200.dp) + ) + + Spacer(modifier = Modifier.height(45.dp)) + + + TextButton( + onClick = onLoginClick, + shape = RoundedCornerShape(0.dp), + modifier = Modifier.height(25.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = "log in without an account", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Thin, + color = Color.Black, + textDecoration = TextDecoration.Underline + ), + fontSize = 8.sp + ) + } + } + } + } + } +} + +@Composable +private fun LoginButton( + text: String, + iconRes: Int, + onClick: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier +) { + val curve = 50.dp + Button( + modifier = modifier + .height(54.dp) + .border( + width = 1.dp, + color = VeryLightGray, + shape = RoundedCornerShape(curve) + ) + .fillMaxWidth(), + onClick = onClick, + shape = RoundedCornerShape(curve), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ), + contentPadding = PaddingValues(horizontal = 20.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = contentDescription, + modifier = Modifier.size(32.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = Color.Black + ), + fontSize = 14.sp + ) + } + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun ClockLogo( + modifier: Modifier = Modifier, + minuteHandRotation: Float, + hourHandRotation: Float +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val clockSize = maxWidth + + val minuteHandWidth = maxWidth * 0.36f + val minuteHandHeight = maxWidth * 0.21f + val hourHandWidth = maxWidth * 0.29f + val hourHandHeight = maxWidth * 0.24f + + Image( + painter = painterResource(id = R.drawable.ic_clock), + contentDescription = "Clock base", + contentScale = ContentScale.FillBounds, + modifier = Modifier.size(clockSize) + ) + + Image( + painter = painterResource(id = R.drawable.ic_hour_hand), + contentDescription = "Hour hand", + contentScale = ContentScale.FillBounds, + modifier = Modifier + .size(hourHandWidth, hourHandHeight) + .graphicsLayer { + val pivotFraction = 11f / 30f + + translationX = (0.5f - pivotFraction) * size.width + translationY = (0.5f - pivotFraction) * size.height + + rotationZ = hourHandRotation + transformOrigin = TransformOrigin(pivotFraction, pivotFraction) + } + ) + + Image( + painter = painterResource(id = R.drawable.ic_minute_hand), + contentDescription = "Minute hand", + contentScale = ContentScale.FillBounds, + modifier = Modifier + .size(minuteHandWidth, minuteHandHeight) + .graphicsLayer { + val pivotFractionX = 2.5f/25f + val pivotFractionY = 16.5f/25f + + translationX = (0.5f - pivotFractionX) * size.width + translationY = (0.5f - pivotFractionY) * size.height + + rotationZ = minuteHandRotation + transformOrigin = TransformOrigin(pivotFractionX, pivotFractionY) + } + ) + } +} + +@Preview +@Composable +fun OnboardingPreview() { + Onboarding() +} diff --git a/app/src/main/res/drawable/ic_clock.xml b/app/src/main/res/drawable/ic_clock.xml new file mode 100644 index 0000000..45922c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_cornell_logo.xml b/app/src/main/res/drawable/ic_cornell_logo.xml new file mode 100644 index 0000000..f93cc0c --- /dev/null +++ b/app/src/main/res/drawable/ic_cornell_logo.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_google_g.xml b/app/src/main/res/drawable/ic_google_g.xml new file mode 100644 index 0000000..021b162 --- /dev/null +++ b/app/src/main/res/drawable/ic_google_g.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_hour_hand.xml b/app/src/main/res/drawable/ic_hour_hand.xml new file mode 100644 index 0000000..9546a8f --- /dev/null +++ b/app/src/main/res/drawable/ic_hour_hand.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_logo.xml b/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000..36826da --- /dev/null +++ b/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_minute_hand.xml b/app/src/main/res/drawable/ic_minute_hand.xml new file mode 100644 index 0000000..e1f907d --- /dev/null +++ b/app/src/main/res/drawable/ic_minute_hand.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_background.xml b/app/src/main/res/drawable/ic_onboarding_background.xml new file mode 100644 index 0000000..1b65158 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_background.xml @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index 20e2a01..132244e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c675daf..0600b4f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,6 @@ composeBom = "2024.09.00" uiGraphics = "1.10.2" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.11.0-alpha01" -kotlinSerialization = "2.2.21" kotlinxSerializationCore = "1.9.0" material3AdaptiveNav3 = "1.3.0-alpha09" @@ -40,5 +39,4 @@ androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3. [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} - +jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}