diff --git a/app/src/main/graphql/Games.graphql b/app/src/main/graphql/Games.graphql index beb702d..0af8c70 100644 --- a/app/src/main/graphql/Games.graphql +++ b/app/src/main/graphql/Games.graphql @@ -10,5 +10,10 @@ query Games{ color } gender + boxScore { + corScore + oppScore + } + result } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/components/UpcomingGameCard.kt b/app/src/main/java/com/cornellappdev/score/components/FeaturedGameCard.kt similarity index 66% rename from app/src/main/java/com/cornellappdev/score/components/UpcomingGameCard.kt rename to app/src/main/java/com/cornellappdev/score/components/FeaturedGameCard.kt index b682303..3d0d0a1 100644 --- a/app/src/main/java/com/cornellappdev/score/components/UpcomingGameCard.kt +++ b/app/src/main/java/com/cornellappdev/score/components/FeaturedGameCard.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,21 +23,28 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.cornellappdev.score.R import com.cornellappdev.score.theme.CornellRed import com.cornellappdev.score.theme.PennBlue +import com.cornellappdev.score.theme.Style.losingScoreText import com.cornellappdev.score.theme.Style.vsText +import com.cornellappdev.score.theme.Style.winningScoreText @Composable -fun UpcomingGameHeader( +fun FeaturedGameHeader( leftTeamLogo: Painter, rightTeamLogo: String, gradientColor1: Color, gradientColor2: Color, - modifier: Modifier = Modifier + isPast: Boolean, + modifier: Modifier = Modifier, + leftScore: Int? = null, + rightScore: Int? = null ) { Box( modifier = modifier @@ -49,7 +58,10 @@ fun UpcomingGameHeader( ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterHorizontally), + horizontalArrangement = if (isPast) Arrangement.spacedBy( + 12.dp, + Alignment.CenterHorizontally + ) else Arrangement.spacedBy(32.dp, Alignment.CenterHorizontally), modifier = Modifier .fillMaxWidth() .padding(vertical = 36.dp) @@ -60,11 +72,33 @@ fun UpcomingGameHeader( contentScale = ContentScale.FillBounds, modifier = Modifier.size(60.dp) ) - - Text( - text = "VS", - style = vsText - ) + if (leftScore != null && rightScore != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = leftScore.toString(), + style = if (leftScore > rightScore) winningScoreText else losingScoreText, + modifier = Modifier + .width(52.dp) + .wrapContentWidth(Alignment.CenterHorizontally) + ) + Text( + text = "-", + style = vsText.copy(fontSize = 32.sp, fontStyle = FontStyle.Normal) + ) + Text( + text = rightScore.toString(), + style = if (leftScore < rightScore) winningScoreText else losingScoreText, + modifier = Modifier + .width(52.dp) + .wrapContentWidth(Alignment.CenterHorizontally) + ) + } + } else { + Text( + text = "VS", + style = vsText + ) + } AsyncImage( model = rightTeamLogo, contentDescription = "Right Team Logo", @@ -76,23 +110,25 @@ fun UpcomingGameHeader( @Preview @Composable -private fun UpcomingGameHeaderPreview() { - UpcomingGameHeader( +private fun FeaturedGameCardPreview() { + FeaturedGameHeader( leftTeamLogo = painterResource(R.drawable.cornell_logo), rightTeamLogo = "https://cornellbigred.com/images/logos/YALE_LOGO_2020.png?width=80&height=80&mode=max", gradientColor1 = CornellRed, gradientColor2 = PennBlue, + isPast = true, modifier = Modifier ) } @Composable -fun UpcomingGameCard( +fun FeaturedGameCard( leftTeamLogo: Painter, rightTeamLogo: String, team: String, location: String, isLive: Boolean, + isPast: Boolean, genderIcon: Painter, sportIcon: Painter, date: String, @@ -100,21 +136,26 @@ fun UpcomingGameCard( gradientColor2: Color, modifier: Modifier = Modifier, headerModifier: Modifier = Modifier, + leftScore: Int? = null, + rightScore: Int? = null ) { Column( modifier = modifier .fillMaxWidth() ) { - UpcomingGameHeader( + FeaturedGameHeader( leftTeamLogo = leftTeamLogo, rightTeamLogo = rightTeamLogo, + leftScore = leftScore, + rightScore = rightScore, gradientColor1 = gradientColor1, gradientColor2 = gradientColor2, + isPast = isPast, modifier = headerModifier ) - SportCard( + GameCard( teamLogo = rightTeamLogo, team = team, date = date, @@ -139,13 +180,16 @@ fun UpcomingGameCard( @Preview(showBackground = true) @Composable private fun GameScheduleScreen() { - UpcomingGameCard( + FeaturedGameCard( leftTeamLogo = painterResource(R.drawable.cornell_logo), rightTeamLogo = "https://cornellbigred.com/images/logos/penn_200x200.png?width=80&height=80&mode=max",//painterResource(R.drawable.penn_logo), + leftScore = 32, + rightScore = 30, team = "Penn", location = "Philadelphia, NJ", date = "5/20/2024", isLive = true, + isPast = false, genderIcon = painterResource(id = R.drawable.ic_gender_men), sportIcon = painterResource(id = R.drawable.ic_baseball), modifier = Modifier, diff --git a/app/src/main/java/com/cornellappdev/score/components/SportCard.kt b/app/src/main/java/com/cornellappdev/score/components/GameCard.kt similarity index 95% rename from app/src/main/java/com/cornellappdev/score/components/SportCard.kt rename to app/src/main/java/com/cornellappdev/score/components/GameCard.kt index d0edb7b..831a32f 100644 --- a/app/src/main/java/com/cornellappdev/score/components/SportCard.kt +++ b/app/src/main/java/com/cornellappdev/score/components/GameCard.kt @@ -8,6 +8,8 @@ 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.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.cornellappdev.score.R +import com.cornellappdev.score.model.GameCardData import com.cornellappdev.score.theme.AmbientColor import com.cornellappdev.score.theme.GrayMedium import com.cornellappdev.score.theme.GrayPrimary @@ -46,9 +49,11 @@ import com.cornellappdev.score.theme.Style.labelsNormal import com.cornellappdev.score.theme.Style.teamName import com.cornellappdev.score.theme.Style.universityText import com.cornellappdev.score.theme.saturatedGreen +import java.util.Date +import java.util.Locale @Composable -fun SportCard( +fun GameCard( teamLogo: String, team: String, date: String, @@ -58,6 +63,7 @@ fun SportCard( sportIcon: Painter, topCornerRound: Boolean, modifier: Modifier = Modifier, + onClick: (Boolean) -> Unit = {} ) { val cardShape = if (topCornerRound) { RoundedCornerShape(16.dp) // Rounded all @@ -88,7 +94,7 @@ fun SportCard( ) ) } - ) + ).clickable { onClick(false) } ) { Column( modifier = Modifier @@ -101,7 +107,7 @@ fun SportCard( modifier = Modifier.fillMaxWidth() ) { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.widthIn(0.dp, 250.dp) ) { AsyncImage( model = teamLogo, @@ -205,9 +211,9 @@ fun SportCard( @Preview(showBackground = true) @Composable -private fun SportCardPreview() { +private fun GameCardPreview() { Column { - SportCard( + GameCard( teamLogo = "https://cornellbigred.com/images/logos/penn_200x200.png?width=80&height=80&mode=max", //painterResource(id = R.drawable.penn_logo), team = "Penn", date = "5/20/2024", diff --git a/app/src/main/java/com/cornellappdev/score/components/UpcomingGamesCarousel.kt b/app/src/main/java/com/cornellappdev/score/components/GamesCarousel.kt similarity index 89% rename from app/src/main/java/com/cornellappdev/score/components/UpcomingGamesCarousel.kt rename to app/src/main/java/com/cornellappdev/score/components/GamesCarousel.kt index 0825066..2f1e652 100644 --- a/app/src/main/java/com/cornellappdev/score/components/UpcomingGamesCarousel.kt +++ b/app/src/main/java/com/cornellappdev/score/components/GamesCarousel.kt @@ -21,12 +21,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.cornellappdev.score.R import com.cornellappdev.score.model.GameCardData +import com.cornellappdev.score.model.GamesCarouselVariant import com.cornellappdev.score.theme.CornellRed import com.cornellappdev.score.theme.CrimsonPrimary import com.cornellappdev.score.theme.GrayLight import com.cornellappdev.score.theme.GrayPrimary import com.cornellappdev.score.theme.Style.heading1 -import com.cornellappdev.score.theme.Style.title import com.cornellappdev.score.util.gameList @Composable @@ -56,7 +56,7 @@ fun DotIndicator( } @Composable -fun UpcomingGamesCarousel(games: List) { +fun GamesCarousel(games: List, variant: GamesCarouselVariant) { val pagerState = rememberPagerState(pageCount = { games.size }) Column( modifier = Modifier @@ -65,7 +65,7 @@ fun UpcomingGamesCarousel(games: List) { verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), ) { Text( - text = "Upcoming", + text = if (variant == GamesCarouselVariant.UPCOMING) "Upcoming" else "Latest", style = heading1, color = GrayPrimary, modifier = Modifier.fillMaxWidth() @@ -76,12 +76,13 @@ fun UpcomingGamesCarousel(games: List) { modifier = Modifier.fillMaxWidth() ) { page -> val game = games[page] - UpcomingGameCard( + FeaturedGameCard( leftTeamLogo = painterResource(R.drawable.cornell_logo), rightTeamLogo = game.teamLogo, team = game.team, date = game.dateString, isLive = game.isLive, + isPast = game.isPast, genderIcon = painterResource(game.genderIcon), sportIcon = painterResource(game.sportIcon), location = game.location, @@ -104,6 +105,6 @@ fun UpcomingGamesCarousel(games: List) { @Preview(showBackground = true, widthDp = 360) @Composable -private fun UpcomingGamesCarouselPreview() { - UpcomingGamesCarousel(gameList) +private fun GamesCarouselPreview() { + GamesCarousel(gameList, GamesCarouselVariant.UPCOMING) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt b/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt new file mode 100644 index 0000000..a3c5c66 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt @@ -0,0 +1,216 @@ +package com.cornellappdev.score.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.cornellappdev.score.R +import com.cornellappdev.score.model.GameCardData +import com.cornellappdev.score.theme.AmbientColor +import com.cornellappdev.score.theme.GrayLight +import com.cornellappdev.score.theme.GrayMedium +import com.cornellappdev.score.theme.GrayPrimary +import com.cornellappdev.score.theme.GrayStroke +import com.cornellappdev.score.theme.SpotColor +import com.cornellappdev.score.theme.Style.heading2 +import com.cornellappdev.score.theme.Style.labelsNormal +import com.cornellappdev.score.theme.Style.metricMedium +import java.time.LocalDate + +@Composable +fun PastGameCard( + data: GameCardData, + modifier: Modifier = Modifier, + onClick: (Boolean) -> Unit = {} +) { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .shadow(elevation = 6.dp, spotColor = SpotColor, ambientColor = AmbientColor) + .then( + Modifier + .border(width = 1.dp, color = GrayStroke, RoundedCornerShape(16.dp)) + ) + .clickable { onClick(true) } + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .width(224.dp) + .drawBehind { + drawLine( + color = GrayLight, + start = Offset(size.width, 0f), + end = Offset(size.width, size.height), + strokeWidth = 1.dp.toPx() + ) + }) { + TeamScore( + data.isHome, + data.teamLogo, + data.team, + data.firstTeamListedWins, + data.cornellScore ?: -1, + data.otherScore ?: -1 + ) + Spacer(modifier = Modifier.height(10.dp)) + TeamScore( + !data.isHome, + data.teamLogo, + data.team, + !data.firstTeamListedWins, + data.cornellScore ?: -1, + data.otherScore ?: -1 + ) + } + Spacer(modifier = Modifier.width(24.dp)) + Column( + modifier = Modifier.height(64.dp), + verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.End + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(data.sportIcon), + contentDescription = "Sport Icon", + modifier = Modifier + .width(24.dp) + .height(24.dp), + tint = Color.Unspecified + ) + Icon( + painter = painterResource(data.genderIcon), + contentDescription = "Gender Icon", + modifier = Modifier + .padding(2.5.dp) + .width(19.dp) + .height(19.dp), + tint = Color.Unspecified + ) + } + Text( + text = data.dateString, + style = labelsNormal, + color = GrayMedium + ) + } + } + } +} + +@Composable +private fun TeamScore( + isCornell: Boolean, + teamLogo: String, + team: String, + winningTeam: Boolean, + cornellScore: Number, + otherScore: Number +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier.widthIn(0.dp, 170.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isCornell) { + Image( + painter = painterResource(R.drawable.cornell_logo), + contentDescription = "Cornell Logo", + modifier = Modifier + .height(27.dp) + .padding(horizontal = 2.dp, vertical = 4.dp) + ) + } else { + AsyncImage( + model = teamLogo, + modifier = Modifier + .height(20.dp) + .padding(horizontal = 4.dp), + contentDescription = "" + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isCornell) "Cornell" else team, + style = heading2, + color = if (winningTeam) GrayPrimary else GrayLight + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (cornellScore == -1 && otherScore == -1) "-" else if (isCornell) cornellScore.toString() else otherScore.toString(), + style = metricMedium, + color = if (winningTeam) GrayPrimary else GrayLight + ) + Spacer(modifier = Modifier.width(12.dp)) + if (winningTeam) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = "Indicates winning team", + modifier = Modifier + .width(11.dp) + .height(14.dp), + ) + } else { + Box(modifier = Modifier.width(11.dp)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PastGameCardPreview() { + val gameCard = GameCardData( + teamLogo = "https://cornellbigred.com/images/logos/penn_200x200.png?width=80&height=80&mode=max", + team = "University of Pennsylvania", + teamColor = Color.Red, + date = LocalDate.of(2025, 3, 24), + dateString = "March 24, 2025", + isLive = false, + isPast = true, + location = "Ithaca, NY", + gender = "Men", + genderIcon = R.drawable.ic_gender_men, // replace with your actual drawable resource + sport = "Basketball", + sportIcon = R.drawable.ic_basketball, // replace with your actual drawable resource + cornellScore = 85, + otherScore = 80 + ) + + Column { + PastGameCard( + data = gameCard + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/model/Game.kt b/app/src/main/java/com/cornellappdev/score/model/Game.kt index bfdbd78..b2e6c79 100644 --- a/app/src/main/java/com/cornellappdev/score/model/Game.kt +++ b/app/src/main/java/com/cornellappdev/score/model/Game.kt @@ -15,8 +15,16 @@ data class Game( val gender: String, val sport: String, val date: String, - val city: String -) + val city: String, + val cornellScore: Number? = null, + val otherScore: Number? = null, +) { + val isPast: Boolean + get() { + val parsedDate = parseDateOrNull(date) ?: LocalDate.MAX + return parsedDate < LocalDate.now() + } +} //Data for HomeScreen game displays data class GameCardData( @@ -26,12 +34,23 @@ data class GameCardData( val date: LocalDate?, val dateString: String, val isLive: Boolean, + val isPast: Boolean, val location: String, val gender: String, val genderIcon: Int, val sport: String, - val sportIcon: Int -) + val sportIcon: Int, + val isHome: Boolean = location == "Ithaca, NY", + val cornellScore: Number? = null, + val otherScore: Number? = null, +) { + val firstTeamListedWins: Boolean + get() { + val cornellWins = (cornellScore?.toFloat() ?: 0f) > (otherScore?.toFloat() ?: 0f) + val firstWins = (cornellWins && isHome) || (!cornellWins && !isHome) + return firstWins + } +} // Scoring information for a specific team, used in the box score data class TeamScore( @@ -103,7 +122,12 @@ enum class GameStatus { COMPLETED } -fun Game.toGameCardData(): GameCardData{ +enum class GamesCarouselVariant { + UPCOMING, + PAST +} + +fun Game.toGameCardData(): GameCardData { return GameCardData( teamLogo = teamLogo, team = teamName, @@ -112,6 +136,7 @@ fun Game.toGameCardData(): GameCardData{ dateString = parseDateOrNull(date)?.format(outputFormatter) ?: date, isLive = (LocalDate.now() == parseDateOrNull(date)), + isPast = isPast, location = city, gender = gender, genderIcon = if (gender == "Mens") R.drawable.ic_gender_men else R.drawable.ic_gender_women, diff --git a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt index f15f496..edac55b 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -39,21 +39,35 @@ class ScoreRepository @Inject constructor( if (result.isSuccess) { val games = result.getOrNull() - val upcomingGameslist: List = + val gamesList: List = games?.games?.mapNotNull { game -> + /** + * The final scores in the past game cards are obtained by parsing a String + * result from the GameQuery, which is oftentimes in the format + * Result, CornellScore-OpponentScore (e.g. "W, 2-1"). Not all of the strings + * are in this format (e.g. 4th of 6, 1498 points for women's Swimming and + * Diving), but in this case, the cornellScore and otherScore parameters of + * the game and associated card should be null, and as of right now, + * null-scored games are filtered out. + */ + val scores = game?.result?.split(",")?.getOrNull(1)?.split("-") + val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() + val otherScore = scores?.getOrNull(1)?.toNumberOrNull() game?.team?.image?.let { Game( teamLogo = it, teamName = game.team.name, - teamColor = parseColor(game.team.color).copy(alpha = 0.4f*255), - gender = game.gender, + teamColor = parseColor(game.team.color).copy(alpha = 0.4f * 255), + gender = if (game.gender == "Mens") "Men's" else "Women's", sport = game.sport, date = game.date, - city = game.city + city = game.city, + cornellScore = cornellScore, + otherScore = otherScore ) } } ?: emptyList() - _upcomingGamesFlow.value = ApiResponse.Success(upcomingGameslist) + _upcomingGamesFlow.value = ApiResponse.Success(gamesList) } else { _upcomingGamesFlow.value = ApiResponse.Error } @@ -64,3 +78,10 @@ class ScoreRepository @Inject constructor( } } } + +fun String.toNumberOrNull(): Number? { + return when { + this.contains(".") -> this.toFloatOrNull() // Try converting to Float if there's a decimal + else -> this.toIntOrNull() // Otherwise, try converting to Int + } +} diff --git a/app/src/main/java/com/cornellappdev/score/model/Sport.kt b/app/src/main/java/com/cornellappdev/score/model/Sport.kt index 239b757..da7ff84 100644 --- a/app/src/main/java/com/cornellappdev/score/model/Sport.kt +++ b/app/src/main/java/com/cornellappdev/score/model/Sport.kt @@ -158,10 +158,10 @@ enum class GenderDivision( val displayName: String ) { FEMALE( - "Womens" + "Women's" ), MALE( - "Mens" + "Men's" ), ALL( "All" diff --git a/app/src/main/java/com/cornellappdev/score/nav/root/RootNavigation.kt b/app/src/main/java/com/cornellappdev/score/nav/root/RootNavigation.kt index 440ad57..46181d0 100644 --- a/app/src/main/java/com/cornellappdev/score/nav/root/RootNavigation.kt +++ b/app/src/main/java/com/cornellappdev/score/nav/root/RootNavigation.kt @@ -1,13 +1,36 @@ package com.cornellappdev.score.nav.root +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.cornellappdev.score.R +import com.cornellappdev.score.nav.root.ScoreRootScreens.Home.toScreen +import com.cornellappdev.score.screen.GameDetailsScreen import com.cornellappdev.score.screen.HomeScreen +import com.cornellappdev.score.screen.PastGamesScreen +import com.cornellappdev.score.theme.CrimsonPrimary +import com.cornellappdev.score.theme.GrayPrimary +import com.cornellappdev.score.theme.Style.bodyMedium +import com.cornellappdev.score.theme.White import kotlinx.serialization.Serializable +import java.time.LocalDate @Composable fun RootNavigation( @@ -15,6 +38,7 @@ fun RootNavigation( ) { val navController = rememberNavController() val uiState = rootNavigationViewModel.collectUiStateValue() + val navBackStackEntry = navController.currentBackStackEntryAsState().value LaunchedEffect(uiState.navigationEvent) { uiState.navigationEvent?.consumeSuspend { screen -> @@ -22,20 +46,61 @@ fun RootNavigation( } } - NavHost( - navController = navController, - startDestination = ScoreRootScreens.Home - ) { - composable { - HomeScreen() - } - - composable { + Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = { + NavigationBar(containerColor = White) { + tabs.map { item -> + val isSelected = item.screen == navBackStackEntry?.toScreen() + NavigationBarItem( + selected = isSelected, + onClick = { navController.navigate(item.screen) }, + icon = { + Icon( + painter = painterResource(id = if (isSelected) item.selectedIcon else item.unselectedIcon), + contentDescription = null, + tint = Color.Unspecified + ) + }, + label = { + Text( + text = item.label, + style = bodyMedium, + color = if (isSelected) { + CrimsonPrimary + } else { + GrayPrimary + } + ) + } + ) + } } + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + NavHost( + navController = navController, + startDestination = ScoreRootScreens.Home + ) { + composable { + HomeScreen(navigateToGameDetails = { + navController.navigate(ScoreRootScreens.GameDetailsPage("")) + }) + } + + composable { + GameDetailsScreen("", onBackArrow = { + navController.navigateUp() + }) - composable { + } + composable { + PastGamesScreen(navigateToGameDetails = { + navController.navigate(ScoreRootScreens.GameDetailsPage("")) + }) + } + } } } } @@ -47,9 +112,38 @@ sealed class ScoreRootScreens { data object Home : ScoreRootScreens() @Serializable - data object GameDetailPage : ScoreRootScreens() + data class GameDetailsPage(val gameId: String) : ScoreRootScreens() @Serializable - data object Onboarding : ScoreRootScreens() + data object ScoresScreen : ScoreRootScreens() + fun NavBackStackEntry.toScreen(): ScoreRootScreens? = + when (destination.route?.substringAfterLast(".")?.substringBefore("/")) { + "Home" -> toRoute() + "GameDetailsPage" -> toRoute() + "ScoresScreen" -> toRoute() + else -> throw IllegalArgumentException("Invalid screen") + } } + +data class NavItem( + val screen: ScoreRootScreens, + val label: String, + val unselectedIcon: Int, + val selectedIcon: Int +) + +val tabs = listOf( + NavItem( + label = "Schedule", + unselectedIcon = R.drawable.ic_schedule, + selectedIcon = R.drawable.ic_schedule_filled, + screen = ScoreRootScreens.Home, + ), + NavItem( + label = "Scores", + unselectedIcon = R.drawable.ic_scores, + selectedIcon = R.drawable.ic_scores_filled, + screen = ScoreRootScreens.ScoresScreen, + ), +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/GameDetailsScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/GameDetailsScreen.kt index 556d02f..e27d4e9 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/GameDetailsScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/GameDetailsScreen.kt @@ -32,10 +32,10 @@ import com.cornellappdev.score.theme.Style.heading3 import com.cornellappdev.score.theme.White @Composable -fun GameDetailsScreen() { +fun GameDetailsScreen(gameId: String = "", onBackArrow: () -> Unit = {}) { Column(modifier = Modifier.background(White).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { // TODO: add navigation - NavigationHeader(title = "Game Details", {}) + NavigationHeader(title = "Game Details", onBackArrow) GameScoreHeader( leftTeamLogo = painterResource(R.drawable.cornell_logo), rightTeamLogo = painterResource(R.drawable.penn_logo), diff --git a/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt index c8c6478..5e5520a 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt @@ -22,10 +22,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.cornellappdev.score.components.SportCard +import com.cornellappdev.score.components.GamesCarousel +import com.cornellappdev.score.components.GameCard import com.cornellappdev.score.components.SportSelectorHeader -import com.cornellappdev.score.components.UpcomingGamesCarousel import com.cornellappdev.score.model.ApiResponse +import com.cornellappdev.score.model.GamesCarouselVariant import com.cornellappdev.score.model.GenderDivision import com.cornellappdev.score.model.SportSelection import com.cornellappdev.score.theme.Style.title @@ -36,13 +37,16 @@ import com.cornellappdev.score.viewmodel.HomeViewModel @Composable fun HomeScreen( - homeViewModel: HomeViewModel = hiltViewModel() + homeViewModel: HomeViewModel = hiltViewModel(), + navigateToGameDetails: (Boolean) -> Unit = {} ) { val uiState = homeViewModel.collectUiStateValue() Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), - modifier = Modifier.statusBarsPadding() + modifier = Modifier + .statusBarsPadding() + .background(Color.White) ) { when (uiState.loadedState) { is ApiResponse.Loading -> { @@ -71,7 +75,8 @@ fun HomeScreen( HomeContent( uiState = uiState, onGenderSelected = { homeViewModel.onGenderSelected(it) }, - onSportSelected = { homeViewModel.onSportSelected(it) } + onSportSelected = { homeViewModel.onSportSelected(it) }, + navigateToGameDetails = navigateToGameDetails ) } } @@ -82,9 +87,10 @@ fun HomeScreen( private fun HomeContent( uiState: HomeUiState, onGenderSelected: (GenderDivision) -> Unit, - onSportSelected: (SportSelection) -> Unit + onSportSelected: (SportSelection) -> Unit, + navigateToGameDetails: (Boolean) -> Unit = {} ) { - UpcomingGamesCarousel(uiState.upcomingGames) + GamesCarousel(uiState.upcomingGames, GamesCarouselVariant.UPCOMING) Column { Text( text = "Game Schedule", @@ -104,7 +110,7 @@ private fun HomeContent( LazyColumn(modifier = Modifier.padding(horizontal = 24.dp)) { items(uiState.filteredGames) { val game = it - SportCard( + GameCard( teamLogo = game.teamLogo, team = game.team, date = game.dateString, @@ -112,8 +118,10 @@ private fun HomeContent( genderIcon = painterResource(game.genderIcon), sportIcon = painterResource(game.sportIcon), location = game.location, - topCornerRound = true + topCornerRound = true, + onClick = navigateToGameDetails ) + Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/app/src/main/java/com/cornellappdev/score/screen/PastGamesScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/PastGamesScreen.kt new file mode 100644 index 0000000..5c1784e --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/screen/PastGamesScreen.kt @@ -0,0 +1,129 @@ +package com.cornellappdev.score.screen + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +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.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.graphics.Color +import com.cornellappdev.score.components.GameCard +import com.cornellappdev.score.components.SportSelectorHeader +import com.cornellappdev.score.components.GamesCarousel +import com.cornellappdev.score.components.PastGameCard +import com.cornellappdev.score.model.ApiResponse +import com.cornellappdev.score.model.GameCardData +import com.cornellappdev.score.model.GamesCarouselVariant +import com.cornellappdev.score.model.GenderDivision +import com.cornellappdev.score.model.SportSelection +import com.cornellappdev.score.theme.Style.heading1 +import com.cornellappdev.score.theme.Style.title +import com.cornellappdev.score.theme.White +import com.cornellappdev.score.viewmodel.HomeUiState +import com.cornellappdev.score.viewmodel.HomeViewModel +import com.cornellappdev.score.viewmodel.PastGamesUiState +import com.cornellappdev.score.viewmodel.PastGamesViewModel + +@Composable +fun PastGamesScreen( + pastGamesViewModel: PastGamesViewModel = hiltViewModel(), + navigateToGameDetails: (Boolean) -> Unit = {} +) { + val uiState = pastGamesViewModel.collectUiStateValue() + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + modifier = Modifier + .statusBarsPadding() + .background(Color.White) + ) { + when (uiState.loadedState) { + is ApiResponse.Loading -> { + //TODO: Add loading screen + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is ApiResponse.Error -> { + //TODO: Add Error screen + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Failed to load games. Please try again.", + ) + } + } + + is ApiResponse.Success -> { + PastGamesContent( + uiState = uiState, + onGenderSelected = { pastGamesViewModel.onGenderSelected(it) }, + onSportSelected = { pastGamesViewModel.onSportSelected(it) }, + navigateToGameDetails = navigateToGameDetails + ) + } + } + } +} + +@Composable +private fun PastGamesContent( + uiState: PastGamesUiState, + onGenderSelected: (GenderDivision) -> Unit, + onSportSelected: (SportSelection) -> Unit, + navigateToGameDetails: (Boolean) -> Unit = {} +) { + GamesCarousel(uiState.pastGames, GamesCarouselVariant.PAST) + Column { + Text( + text = "All Scores", + style = title, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + SportSelectorHeader( + sports = uiState.selectionList, + selectedGender = uiState.selectedGender, + selectedSport = uiState.sportSelect, + onGenderSelected = onGenderSelected, + onSportSelected = onSportSelected + ) + LazyColumn(modifier = Modifier.padding(horizontal = 24.dp)) { + items(uiState.filteredGames) { + val game = it + PastGameCard( + data = game, + onClick = navigateToGameDetails + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/theme/TextStyle.kt b/app/src/main/java/com/cornellappdev/score/theme/TextStyle.kt index 3f4d54d..a09e045 100644 --- a/app/src/main/java/com/cornellappdev/score/theme/TextStyle.kt +++ b/app/src/main/java/com/cornellappdev/score/theme/TextStyle.kt @@ -24,7 +24,12 @@ object Style { fontFamily = poppinsFamily, fontWeight = FontWeight(700), fontStyle = FontStyle.Italic, - color = Color.White + color = Color.White, + shadow = Shadow( + color = Color(0f, 0f, 0f, 0.4f), + offset = Offset(0f, 0f), + blurRadius = 4f + ) ) val universityText = TextStyle( @@ -197,9 +202,40 @@ object Style { fontWeight = FontWeight(400) ) + val metricMedium = TextStyle( + fontSize = 18.sp, + fontFamily = poppinsFamily, + fontWeight = FontWeight(500) + ) + val metricSemibold = TextStyle( fontSize = 18.sp, fontFamily = poppinsFamily, fontWeight = FontWeight(600) ) + + val winningScoreText = TextStyle( + fontSize = 32.sp, + fontFamily = poppinsFamily, + fontWeight = FontWeight.SemiBold, // FontWeight(600) = SemiBold + color = Color.White, + shadow = Shadow( + color = Color(163f, 239f, 32f, 0.5f), // RGBA(163, 239, 32, 0.50) + offset = Offset(0f, 0f), // No offset, just blur + blurRadius = 6f // Matches 6px blur in CSS + ) + ) + + val losingScoreText = TextStyle( + fontSize = 32.sp, + fontFamily = poppinsFamily, + fontWeight = FontWeight.SemiBold, // FontWeight(600) = SemiBold + color = Color.White.copy(alpha = 0.6f), + shadow = Shadow( + color = Color(0f, 0f, 0f, 0.4f), // RGBA(163, 239, 32, 0.50) + offset = Offset(0f, 0f), // No offset, just blur + blurRadius = 4f // Matches 6px blur in CSS + ) + ) + } diff --git a/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt b/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt index ac4c45a..12962be 100644 --- a/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt +++ b/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt @@ -18,6 +18,7 @@ val PENN_GAME = GameCardData( date = LocalDate.now(), dateString = "3/1/25", isLive = false, + isPast = true, location = "Philadelphia, PA", gender = "Male", genderIcon = R.drawable.ic_gender_men, @@ -32,6 +33,7 @@ val PRINCETON_GAME = GameCardData( date = LocalDate.now(), dateString = "3/1/25", isLive = false, + isPast = true, location = "Boston, MA", gender = "Female", genderIcon = R.drawable.ic_gender_men, diff --git a/app/src/main/java/com/cornellappdev/score/viewmodel/PastGamesViewModel.kt b/app/src/main/java/com/cornellappdev/score/viewmodel/PastGamesViewModel.kt new file mode 100644 index 0000000..4df8393 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/viewmodel/PastGamesViewModel.kt @@ -0,0 +1,77 @@ +package com.cornellappdev.score.viewmodel + +import com.cornellappdev.score.model.ApiResponse +import com.cornellappdev.score.model.GameCardData +import com.cornellappdev.score.model.GenderDivision +import com.cornellappdev.score.model.ScoreRepository +import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection +import com.cornellappdev.score.model.map +import com.cornellappdev.score.model.toGameCardData +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import javax.inject.Inject + +data class PastGamesUiState( + val selectedGender: GenderDivision, + val sportSelect: SportSelection, + val selectionList: List, + val loadedState: ApiResponse> +) { + //TODO: refactor filters to use flows - not best practice to expose original games list to the view + val filteredGames: List + get() = when (loadedState) { + is ApiResponse.Success -> loadedState.data.filter { game -> + (selectedGender == GenderDivision.ALL || game.gender == selectedGender.displayName) && + (sportSelect is SportSelection.All || (sportSelect is SportSelection.SportSelect && game.sport == sportSelect.sport.displayName)) + } + + ApiResponse.Loading -> emptyList() + ApiResponse.Error -> emptyList() + } + val pastGames: List = filteredGames.take(3) +} + +@HiltViewModel +class PastGamesViewModel @Inject constructor( + private val scoreRepository: ScoreRepository +) : BaseViewModel( + PastGamesUiState( + selectedGender = GenderDivision.ALL, + sportSelect = SportSelection.All, + selectionList = Sport.getSportSelectionList(), + loadedState = ApiResponse.Loading + ) +) { + init { + asyncCollect(scoreRepository.upcomingGamesFlow) { response -> + applyMutation { + copy( + loadedState = response.map { list -> + list.map { game -> + game.toGameCardData() + }.filter { game -> + game.date?.isBefore(LocalDate.now()) ?: false + }.sortedByDescending { it.date } + } + ) + } + } + } + + fun onGenderSelected(gender: GenderDivision) { + applyMutation { + copy( + selectedGender = gender + ) + } + } + + fun onSportSelected(sport: SportSelection) { + applyMutation { + copy( + sportSelect = sport + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..bfb3f18 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseball.xml b/app/src/main/res/drawable/ic_baseball.xml index 7b17ada..09fa23f 100644 --- a/app/src/main/res/drawable/ic_baseball.xml +++ b/app/src/main/res/drawable/ic_baseball.xml @@ -1,9 +1,12 @@ + + android:width="32dp" + android:height="32dp" + android:viewportWidth="32" + android:viewportHeight="32"> + + android:fillColor="#777777" + android:pathData="M7.523,23.07C8.64,22.257 9.505,21.221 10.118,19.963C10.73,18.705 11.036,17.384 11.036,16C11.036,14.616 10.73,13.293 10.118,12.03C9.505,10.767 8.64,9.727 7.523,8.908C6.691,9.909 6.063,11.014 5.641,12.223C5.22,13.433 5.009,14.692 5.009,16C5.009,17.308 5.22,18.565 5.641,19.77C6.063,20.975 6.691,22.075 7.523,23.07ZM16,26.991C17.349,26.991 18.643,26.766 19.883,26.315C21.123,25.863 22.252,25.189 23.272,24.291C22.015,23.267 21.041,22.031 20.349,20.582C19.657,19.134 19.311,17.606 19.311,16C19.311,14.394 19.657,12.864 20.349,11.411C21.041,9.957 22.015,8.719 23.272,7.696C22.252,6.807 21.123,6.137 19.883,5.685C18.643,5.234 17.349,5.009 16,5.009C14.651,5.009 13.357,5.234 12.117,5.685C10.877,6.137 9.748,6.807 8.728,7.696C9.985,8.719 10.962,9.957 11.662,11.411C12.361,12.864 12.711,14.394 12.711,16C12.711,17.606 12.361,19.134 11.662,20.582C10.962,22.031 9.985,23.267 8.728,24.291C9.748,25.189 10.877,25.863 12.117,26.315C13.357,26.766 14.651,26.991 16,26.991ZM24.491,23.07C25.329,22.075 25.955,20.975 26.37,19.77C26.784,18.565 26.991,17.308 26.991,16C26.991,14.692 26.784,13.433 26.37,12.223C25.955,11.014 25.329,9.909 24.491,8.908C23.373,9.727 22.51,10.767 21.9,12.03C21.291,13.293 20.986,14.616 20.986,16C20.986,17.384 21.291,18.705 21.9,19.963C22.51,21.221 23.373,22.257 24.491,23.07ZM16.002,28.667C14.259,28.667 12.617,28.334 11.076,27.669C9.534,27.004 8.191,26.1 7.046,24.955C5.901,23.811 4.996,22.469 4.331,20.928C3.666,19.387 3.333,17.745 3.333,16.002C3.333,14.25 3.666,12.606 4.331,11.069C4.996,9.532 5.901,8.191 7.045,7.046C8.189,5.901 9.531,4.996 11.072,4.331C12.613,3.666 14.255,3.333 15.998,3.333C17.75,3.333 19.394,3.666 20.931,4.331C22.468,4.996 23.809,5.9 24.954,7.045C26.099,8.189 27.004,9.529 27.669,11.065C28.334,12.602 28.667,14.246 28.667,15.998C28.667,17.741 28.334,19.383 27.669,20.924C27.004,22.466 26.1,23.809 24.955,24.954C23.811,26.099 22.471,27.004 20.935,27.669C19.398,28.334 17.754,28.667 16.002,28.667Z"/> + diff --git a/app/src/main/res/drawable/ic_schedule.xml b/app/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 0000000..98cf69f --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_schedule_filled.xml b/app/src/main/res/drawable/ic_schedule_filled.xml new file mode 100644 index 0000000..80edcd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_filled.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_scores.xml b/app/src/main/res/drawable/ic_scores.xml new file mode 100644 index 0000000..35eab9c --- /dev/null +++ b/app/src/main/res/drawable/ic_scores.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_scores_filled.xml b/app/src/main/res/drawable/ic_scores_filled.xml new file mode 100644 index 0000000..9efff6f --- /dev/null +++ b/app/src/main/res/drawable/ic_scores_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8b8921..cfdf911 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ activity = "1.8.0" constraintlayout = "2.1.4" runtimeAndroid = "1.7.2" apollo = "4.1.1" +media3CommonKtx = "1.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -25,6 +26,7 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" } apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime" } +androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3CommonKtx" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }