diff --git a/.github/workflows/android-lint.yml b/.github/workflows/android-lint.yml index 968fbe3..e329021 100644 --- a/.github/workflows/android-lint.yml +++ b/.github/workflows/android-lint.yml @@ -15,13 +15,19 @@ jobs: steps: - uses: actions/checkout@v3 + - name: set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' cache: gradle + + - name: Create google-services.json + run: | + echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 --decode > app/google-services.json - name: Run Lint run: ./gradlew lint - continue-on-error: false \ No newline at end of file + continue-on-error: false + diff --git a/app/.gitignore b/app/.gitignore index 42afabf..7ecd011 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +app/google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9c1217..69e5c70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("com.google.dagger.hilt.android") id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" // this version matches your Kotlin version id("org.jetbrains.kotlin.plugin.serialization") - + id("com.google.gms.google-services") } @@ -72,6 +72,8 @@ dependencies { implementation(libs.material3) implementation("com.google.dagger:hilt-android:2.51.1") implementation(libs.androidx.material3) + implementation(libs.androidx.foundation) + implementation(libs.androidx.compose.material3.material3) kapt("com.google.dagger:hilt-android-compiler:2.51.1") implementation("androidx.hilt:hilt-navigation-compose:1.0.0") implementation("com.google.accompanist:accompanist-pager:0.24.0-alpha") @@ -88,12 +90,13 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - implementation(libs.apollo.runtime) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation(libs.apollo.runtime) implementation("io.coil-kt.coil3:coil-compose:3.1.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0") lintChecks(libs.compose.lint.checks) + implementation(platform("com.google.firebase:firebase-bom:34.3.0")) + implementation("com.google.firebase:firebase-analytics") } apollo { diff --git a/app/src/main/graphql/FragmentedGame.graphql b/app/src/main/graphql/FragmentedGame.graphql new file mode 100644 index 0000000..4f5d398 --- /dev/null +++ b/app/src/main/graphql/FragmentedGame.graphql @@ -0,0 +1,33 @@ +query PagedGames($limit: Int!, $offset: Int!) { + games(limit: $limit, offset: $offset) { + id + city + date + gender + location + opponentId + result + sport + state + time + scoreBreakdown + utcDate + team { + id + color + image + name + } + boxScore { + team + period + time + description + scorer + assist + scoreBy + corScore + oppScore + } + } +} diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 0265edb..e1ebfad 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -1,13 +1,15 @@ type Query { + articles(sportsType: String): [ArticleType] + youtubeVideos: [YoutubeVideoType] youtubeVideo(id: String!): YoutubeVideoType - games: [GameType] + games("Number of games to return" limit: Int = 100, "Number of games to skip" offset: Int = 0): [GameType] game(id: String!): GameType - gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!): GameType + gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!, ticketLink: String): GameType gamesBySport(sport: String!): [GameType] @@ -22,6 +24,30 @@ type Query { teamByName(name: String!): TeamType } +""" +A GraphQL type representing a news article. + +Attributes: + - title: The title of the article + - image: The filename of the article's main image + - sports_type: The specific sport category + - published_at: The publication date + - url: The URL to the full article +""" +type ArticleType { + id: String + + title: String! + + image: String + + sportsType: String! + + publishedAt: String! + + url: String! +} + """ A GraphQL type representing a YouTube video. @@ -42,6 +68,8 @@ type YoutubeVideoType { thumbnail: String! + b64Thumbnail: String! + url: String! publishedAt: String! @@ -63,6 +91,7 @@ Attributes: - `time`: The time of the game. (optional) - `box_score`: The box score of the game. - `score_breakdown`: The score breakdown of the game. + - `ticket_link`: The ticket link of the game. (optional) """ type GameType { id: String @@ -90,6 +119,10 @@ type GameType { scoreBreakdown: [[String]] team: TeamType + + utcDate: String + + ticketLink: String } """ @@ -133,6 +166,7 @@ Attributes: - `id`: The ID of the team (optional). - `color`: The color of the team. - `image`: The image of the team (optional). + - `b64_image`: The base64 encoded image of the team (optional). - `name`: The name of the team. """ type TeamType { @@ -142,6 +176,8 @@ type TeamType { image: String + b64Image: String + name: String! } @@ -149,17 +185,22 @@ type Mutation { """ Creates a new game. """ - createGame(boxScore: String, city: String!, date: String!, gender: String!, location: String, opponentId: String!, result: String, scoreBreakdown: String, sport: String!, state: String!, time: String!): CreateGame + createGame(boxScore: String, city: String!, date: String!, gender: String!, location: String, opponentId: String!, result: String, scoreBreakdown: String, sport: String!, state: String!, ticketLink: String, time: String!, utcDate: String): CreateGame """ Creates a new team. """ - createTeam(color: String!, image: String, name: String!): CreateTeam + createTeam(b64Image: String, color: String!, image: String, name: String!): CreateTeam """ Creates a new youtube video. """ - createYoutubeVideo(description: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo + createYoutubeVideo(b64Thumbnail: String!, description: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo + + """ + Creates a new article. + """ + createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle } type CreateGame { @@ -174,6 +215,10 @@ type CreateYoutubeVideo { youtubeVideo: YoutubeVideoType } +type CreateArticle { + article: ArticleType +} + """ A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation and subscription operations. """ diff --git a/app/src/main/java/com/cornellappdev/score/components/ButtonPrimary.kt b/app/src/main/java/com/cornellappdev/score/components/ButtonPrimary.kt index 6c9b046..4aa4108 100644 --- a/app/src/main/java/com/cornellappdev/score/components/ButtonPrimary.kt +++ b/app/src/main/java/com/cornellappdev/score/components/ButtonPrimary.kt @@ -18,9 +18,16 @@ import com.cornellappdev.score.theme.Style.bodyMedium import com.cornellappdev.score.theme.White @Composable -fun ButtonPrimary(text: String, icon: Painter?, onClick: () -> Unit = {}) { - Button(onClick = onClick, +fun ButtonPrimary( + text: String, + icon: Painter?, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + Button( + onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = CrimsonPrimary), + modifier = modifier, contentPadding = PaddingValues(12.dp) ) { if (icon != null) { @@ -32,7 +39,9 @@ fun ButtonPrimary(text: String, icon: Painter?, onClick: () -> Unit = {}) { .height(24.dp), colorFilter = ColorFilter.tint(White) ) - Spacer(modifier = Modifier.width(8.dp)) + if (text.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + } } Text(text = text, style = bodyMedium.copy(color = White)) } diff --git a/app/src/main/java/com/cornellappdev/score/components/ExpandableSection.kt b/app/src/main/java/com/cornellappdev/score/components/ExpandableSection.kt new file mode 100644 index 0000000..f1cf06b --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/ExpandableSection.kt @@ -0,0 +1,123 @@ +package com.cornellappdev.score.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.score.R +import com.cornellappdev.score.theme.Style.bodyNormal +import com.cornellappdev.score.theme.Style.heading2 + +interface DisplayableFilter { + val displayName: String +} + +enum class PriceFilter(override val displayName: String) : DisplayableFilter { + UNTICKETED("Unticketed"), + UNDER_20("Under $20"), + UNDER_50("Under $50"), + OVER_50("Over $50") +} + +enum class LocationFilter(override val displayName: String) : DisplayableFilter { + ON_CAMPUS("On Campus"), + ONE_TO_TWO_HOURS("1-2 Hours"), + TWO_TO_FOUR_HOURS("2-4 Hours"), + OVER_FOUR_HOURS("Over 4 Hours") +} + +enum class DateFilter(override val displayName: String) : DisplayableFilter { + TODAY("Today"), + WITHIN_7_DAYS("Within 7 Days"), + WITHIN_A_MONTH("Within a Month"), + OVER_A_MONTH("Over a Month") +} + +@Composable +fun ExpandableSection( + title: String, + options: List, + selectedOption: T?, + onOptionSelected: (T?) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = heading2) + Icon( + painter = painterResource( + id = if (expanded) R.drawable.ic_round_minus else R.drawable.ic_round_plus + ), + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + // Options + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + ) { + if (expanded) { + Column { + options.forEach { option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onOptionSelected(option) } + .padding(start = 16.dp, top = 4.dp, bottom = 4.dp) + ) { + ScoreRadioButton( + selected = (selectedOption == option), + onClick = { onOptionSelected(option) }, + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp) + ) + Text(option.displayName, style = bodyNormal) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExpandableSectionPreview() = ScorePreview { + var selected by remember { mutableStateOf(null) } + + Column(modifier = Modifier.padding(16.dp)) { + ExpandableSection( + title = "Price", + options = PriceFilter.entries, + selectedOption = selected, + onOptionSelected = { selected = it } + ) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/components/IconButton.kt b/app/src/main/java/com/cornellappdev/score/components/IconButton.kt new file mode 100644 index 0000000..603e466 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/IconButton.kt @@ -0,0 +1,63 @@ +package com.cornellappdev.score.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.score.R + +@Composable +fun IconButton( + icon: Painter, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, + borderColor: Color = Color(0xFFD6D6D6) +) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(100.dp), + border = BorderStroke(1.dp, borderColor), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 12.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + tint = tint + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun IconButtonPreview() = ScorePreview { + IconButton( + icon = painterResource(id = R.drawable.advanced_filter), + contentDescription = "Example Icon", + onClick = {} + ) +} + diff --git a/app/src/main/java/com/cornellappdev/score/components/ScoreRadioButton.kt b/app/src/main/java/com/cornellappdev/score/components/ScoreRadioButton.kt new file mode 100644 index 0000000..0d9d760 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/ScoreRadioButton.kt @@ -0,0 +1,44 @@ +package com.cornellappdev.score.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.score.theme.GrayPrimary + +@Composable +fun ScoreRadioButton( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + RadioButton( + selected = selected, + onClick = onClick, + modifier = modifier, + colors = RadioButtonDefaults.colors( + selectedColor = GrayPrimary, + unselectedColor = GrayPrimary + ) + ) +} + +@Preview +@Composable +private fun ScoreRadioButtonPreview() = ScorePreview { + Column(modifier = Modifier.padding(16.dp)) { + ScoreRadioButton( + selected = false, + onClick = {} + ) + ScoreRadioButton( + selected = true, + onClick = {} + ) + } +} 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 1310cd4..2b84524 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -6,6 +6,7 @@ import com.cornellappdev.score.util.isValidSport import com.cornellappdev.score.util.parseColor import com.example.score.GameByIdQuery import com.example.score.GamesQuery +import com.example.score.PagedGamesQuery import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,6 +17,9 @@ import javax.inject.Inject import javax.inject.Singleton private const val TIMEOUT_TIME_MILLIS = 5000L +private const val PAGE_LIMIT = 100 +private const val MAX_RETRIES = 3 +private const val PAGE_TIMEOUT_MILLIS = 3000L /** * This is a singleton responsible for fetching and caching all data for Score. @@ -40,7 +44,7 @@ class ScoreRepository @Inject constructor( * Asynchronously fetches the list of games from the API. Once finished, will send down * `upcomingGamesFlow` to be observed. */ - fun fetchGames() = appScope.launch { + fun fetchGamesPrev() = appScope.launch { _upcomingGamesFlow.value = ApiResponse.Loading try { val result = @@ -92,6 +96,76 @@ class ScoreRepository @Inject constructor( } } + fun fetchGames() = appScope.launch { + _upcomingGamesFlow.value = ApiResponse.Loading + val allGames = mutableListOf() + var offset = 0 + var retries = 0 + + try { + while (true) { + val pageResult = runCatching { + withTimeout(PAGE_TIMEOUT_MILLIS) { + apolloClient.query( + PagedGamesQuery(limit = PAGE_LIMIT, offset = offset) + ).execute().data?.games + } + }.getOrNull() + + if (pageResult == null) { + if (retries < MAX_RETRIES) { + retries++ + continue + } else { + break + } + } + + if (pageResult.isEmpty()) { + break + } + + retries = 0 + + val pageGames: List = pageResult + .filterNotNull() + .filter { gql -> isValidSport(gql.sport ?: "") } + .mapNotNull { graphqlGame -> + val scores = graphqlGame.result?.split(",")?.getOrNull(1)?.split("-") + val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() + val otherScore = scores?.getOrNull(1)?.toNumberOrNull() + graphqlGame.team?.image?.let { imageUrl -> + Game( + id = graphqlGame.id ?: "", + teamLogo = imageUrl, + teamName = graphqlGame.team.name, + teamColor = parseColor(graphqlGame.team.color).copy(alpha = 0.4f * 255), + gender = if (graphqlGame.gender == "Mens") "Men's" else "Women's", + sport = graphqlGame.sport, + date = graphqlGame.date, + city = graphqlGame.city, + cornellScore = cornellScore, + otherScore = otherScore + ) + } + } + + allGames.addAll(pageGames) + + if (pageResult.size < PAGE_LIMIT) break + offset += PAGE_LIMIT + } + + _upcomingGamesFlow.value = + if (allGames.isNotEmpty()) ApiResponse.Success(allGames) + else ApiResponse.Error + + } catch (e: Exception) { + Log.e("ScoreRepository", "Error fetching upcoming games", e) + _upcomingGamesFlow.value = ApiResponse.Error + } + } + /** * Asynchronously fetches game details for a particular game. Once finished, will update * `currentGamesFlow` to be observed. @@ -113,7 +187,6 @@ class ScoreRepository @Inject constructor( _currentGameFlow.value = ApiResponse.Error } } - } fun String.toNumberOrNull(): Number? { @@ -122,3 +195,4 @@ fun String.toNumberOrNull(): Number? { else -> this.toIntOrNull() // Otherwise, try converting to Int } } + 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 3145dfc..1758236 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt @@ -2,10 +2,12 @@ package com.cornellappdev.score.screen import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,21 +17,35 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.score.R +import com.cornellappdev.score.components.ButtonPrimary +import com.cornellappdev.score.components.DateFilter import com.cornellappdev.score.components.EmptyStateBox import com.cornellappdev.score.components.ErrorState +import com.cornellappdev.score.components.ExpandableSection import com.cornellappdev.score.components.GameCard import com.cornellappdev.score.components.GamesCarousel +import com.cornellappdev.score.components.IconButton import com.cornellappdev.score.components.LoadingScreen +import com.cornellappdev.score.components.LocationFilter +import com.cornellappdev.score.components.PriceFilter import com.cornellappdev.score.components.ScorePreview import com.cornellappdev.score.components.ScorePullToRefreshBox import com.cornellappdev.score.components.SportSelectorHeader @@ -46,13 +62,15 @@ import com.cornellappdev.score.util.sportSelectionList import com.cornellappdev.score.viewmodel.HomeUiState import com.cornellappdev.score.viewmodel.HomeViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), navigateToGameDetails: (String) -> Unit = {} ) { val uiState = homeViewModel.collectUiStateValue() - + var showBottomSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), modifier = Modifier @@ -73,13 +91,75 @@ fun HomeScreen( onGenderSelected = { homeViewModel.onGenderSelected(it) }, onSportSelected = { homeViewModel.onSportSelected(it) }, navigateToGameDetails = navigateToGameDetails, - onRefresh = { homeViewModel.onRefresh() } + onRefresh = { homeViewModel.onRefresh() }, + onAdvancedFilterClick = { showBottomSheet = true } ) } } + if (showBottomSheet) { + var selectedPrice by remember { mutableStateOf(null) } + var selectedLocation by remember { mutableStateOf(null) } + var selectedDate by remember { mutableStateOf(null) } + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 32.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + ExpandableSection( + title = "Price", + options = PriceFilter.entries, + selectedOption = selectedPrice, + onOptionSelected = { selectedPrice = it } + ) + + ExpandableSection( + title = "Location", + options = LocationFilter.entries, + selectedOption = selectedLocation, + onOptionSelected = { selectedLocation = it } + ) + + ExpandableSection( + title = "Date of Game", + options = DateFilter.entries, + selectedOption = selectedDate, + onOptionSelected = { selectedDate = it } + ) + + ButtonPrimary( + text = "Apply", + icon = null, + modifier = Modifier.fillMaxWidth(), + onClick = { + // TODO: Apply filter logic via ViewModel + showBottomSheet = false + } + ) + + Text( + "Reset", + fontSize = 14.sp, + modifier = Modifier + .fillMaxWidth() + .clickable { + // TODO: Reset filter logic + showBottomSheet = false + }, + textAlign = TextAlign.Center + ) + } + } + } } } + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable private fun HomeContent( @@ -87,10 +167,17 @@ private fun HomeContent( onGenderSelected: (GenderDivision) -> Unit, onSportSelected: (SportSelection) -> Unit, onRefresh: () -> Unit, - navigateToGameDetails: (String) -> Unit = {} + navigateToGameDetails: (String) -> Unit = {}, + onAdvancedFilterClick: () -> Unit ) { ScorePullToRefreshBox(isRefreshing = uiState.loadedState == ApiResponse.Loading, onRefresh) { - HomeLazyColumn(uiState, onGenderSelected, onSportSelected, navigateToGameDetails) + HomeLazyColumn( + uiState, + onGenderSelected, + onSportSelected, + navigateToGameDetails, + onAdvancedFilterClick + ) } } @@ -100,7 +187,8 @@ private fun HomeLazyColumn( uiState: HomeUiState, onGenderSelected: (GenderDivision) -> Unit, onSportSelected: (SportSelection) -> Unit, - navigateToGameDetails: (String) -> Unit + navigateToGameDetails: (String) -> Unit, + onAdvancedFilterClick: () -> Unit ) { LazyColumn(contentPadding = PaddingValues(top = 24.dp)) { if (uiState.filteredGames.isNotEmpty()) { @@ -130,12 +218,21 @@ private fun HomeLazyColumn( .padding(horizontal = 24.dp) ) { Spacer(Modifier.height(24.dp)) - Text( - text = "Game Schedule", - style = title, - modifier = Modifier - .fillMaxWidth() - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Game Schedule", + style = title, + ) + IconButton( + icon = painterResource(id = R.drawable.advanced_filter), + contentDescription = "Advanced filter", + onClick = onAdvancedFilterClick + ) + } Spacer(modifier = Modifier.height(8.dp)) SportSelectorHeader( sports = uiState.selectionList, @@ -200,6 +297,7 @@ private fun HomeScreenPreview() = ScorePreview { onGenderSelected = {}, onSportSelected = {}, onRefresh = {}, + onAdvancedFilterClick = {} ) } } @@ -216,7 +314,8 @@ private fun HomeScreenEmptyStatePreview() = ScorePreview { ), onGenderSelected = {}, onSportSelected = {}, - onRefresh = {} + onRefresh = {}, + onAdvancedFilterClick = {} ) } diff --git a/app/src/main/res/drawable/advanced_filter.xml b/app/src/main/res/drawable/advanced_filter.xml new file mode 100644 index 0000000..62010d4 --- /dev/null +++ b/app/src/main/res/drawable/advanced_filter.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_selected.xml b/app/src/main/res/drawable/ic_radio_selected.xml new file mode 100644 index 0000000..14a7116 --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_selected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_radio_unselected.xml b/app/src/main/res/drawable/ic_radio_unselected.xml new file mode 100644 index 0000000..291ddad --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_unselected.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_minus.xml b/app/src/main/res/drawable/ic_round_minus.xml new file mode 100644 index 0000000..cbca170 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_minus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_plus.xml b/app/src/main/res/drawable/ic_round_plus.xml new file mode 100644 index 0000000..eb042ed --- /dev/null +++ b/app/src/main/res/drawable/ic_round_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts index 61d5852..648673c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,9 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.jetbrainsKotlinAndroid)version "1.9.10" apply false + alias(libs.plugins.jetbrainsKotlinAndroid) version "1.9.10" apply false id("com.google.dagger.hilt.android") version "2.51.1" apply false kotlin("jvm") version "2.0.20" kotlin("plugin.serialization") version "2.0.20" + id("com.google.gms.google-services") version "4.4.3" apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b53ee5a..0edc01b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ media3CommonKtx = "1.5.1" # See https://stackoverflow.com/a/79126321 material3 = "1.4.0-alpha11" material3Version = "1.3.2" +foundation = "1.9.4" +androidxMaterial3 = "1.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,9 +32,11 @@ material3 = { group = "androidx.compose.material3", name = "material3", version. androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } 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" } +apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3CommonKtx" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3Version" } +androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +androidx-compose-material3-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxMaterial3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }