diff --git a/app/src/main/graphql/Highlights.graphql b/app/src/main/graphql/Highlights.graphql new file mode 100644 index 0000000..fa8b5d4 --- /dev/null +++ b/app/src/main/graphql/Highlights.graphql @@ -0,0 +1,17 @@ +query Highlights($sportsType: String) { + articles(sportsType: $sportsType) { + title + image + sportsType + publishedAt + url + } + youtubeVideos { + title + thumbnail + url + publishedAt + duration + sportsType + } +} \ No newline at end of file diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index e1ebfad..7ccb1bb 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -17,6 +17,8 @@ type Query { gamesBySportGender(sport: String!, gender: String!): [GameType] + gamesByDate(startDate: DateTime!, endDate: DateTime!): [GameType] + teams: [TeamType] team(id: String!): TeamType @@ -58,6 +60,8 @@ Attributes: - thumbnail: The URL of the video's thumbnail. - url: The URL to the video. - published_at: The date and time the video was published. + - duration: The duration of the video (optional). + - sportsType: The sport type extracted from the video title. """ type YoutubeVideoType { id: String @@ -73,6 +77,10 @@ type YoutubeVideoType { url: String! publishedAt: String! + + duration: String + + sportsType: String } """ @@ -181,6 +189,13 @@ type TeamType { name: String! } +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + type Mutation { """ Creates a new game. @@ -195,7 +210,7 @@ type Mutation { """ Creates a new youtube video. """ - createYoutubeVideo(b64Thumbnail: String!, description: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo + createYoutubeVideo(b64Thumbnail: String!, description: String!, duration: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo """ Creates a new article. diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/ArticleHighlightsCard.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/ArticleHighlightsCard.kt index bc34227..30d2630 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/ArticleHighlightsCard.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/ArticleHighlightsCard.kt @@ -95,7 +95,7 @@ fun ArticleHighlightCard( Text( color = Color.White, style = labelsNormal, - text = articleHighlight.date + text = articleHighlight.dateString ) } } @@ -110,6 +110,7 @@ private fun ArticleHighlightCardPreview() { "Late Goal Lifts No. 6 Men’s Hockey Over Brown", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/9", Sport.ICE_HOCKEY ), @@ -125,6 +126,7 @@ private fun WideArticleHighlightCardPreview() { "Late Goal Lifts No. 6 Men’s Hockey Over Brown", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/9", Sport.ICE_HOCKEY ), diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardLazyColumn.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardLazyColumn.kt index 2a9d968..134fdce 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardLazyColumn.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardLazyColumn.kt @@ -26,49 +26,30 @@ import com.cornellappdev.score.theme.Style.bodyNormal import com.cornellappdev.score.util.highlightsList import com.cornellappdev.score.util.recentSearchList -sealed interface SearchResultsState { - data object Recent : SearchResultsState - data class Results(val items: List) : SearchResultsState - data object Empty : SearchResultsState -} +enum class SearchUiState { RECENT, EMPTY, RESULTS } @Composable fun HighlightsCardLazyColumn( recentSearchList: List, query: String, - highlightsList: List, - onItemClick: () -> Unit, - onCloseClick: () -> Unit, + filteredResults: List, + onItemClick: (String) -> Unit, + onCloseClick: (String) -> Unit, numResultsHeader: (@Composable () -> Unit)? = null ) { Column( modifier = Modifier.padding(horizontal = 24.dp) ) { - /*todo: move to VM*/ - val resultsState: SearchResultsState = - when { - recentSearchList.isNotEmpty() && query.isEmpty() -> - SearchResultsState.Recent - - query.isNotEmpty() -> { - val filtered = highlightsList.filter { - it.title.contains(query, ignoreCase = true) - } - - if (filtered.isEmpty()) { - SearchResultsState.Empty - } else { - SearchResultsState.Results(filtered) - } - } - - else -> SearchResultsState.Recent - } + val uiStateKey = when { + query.isEmpty() -> SearchUiState.RECENT + filteredResults.isEmpty() -> SearchUiState.EMPTY + else -> SearchUiState.RESULTS + } AnimatedContent( - targetState = resultsState, + targetState = uiStateKey, transitionSpec = { (fadeIn() + slideInVertically { it / 8 }) togetherWith (fadeOut() + slideOutVertically { -it / 8 }) @@ -77,7 +58,7 @@ fun HighlightsCardLazyColumn( ) { state -> when (state) { - SearchResultsState.Recent -> { + SearchUiState.RECENT -> { RecentSearches( recentSearchList, onItemClick, @@ -85,21 +66,21 @@ fun HighlightsCardLazyColumn( ) } - SearchResultsState.Empty -> { + SearchUiState.EMPTY -> { EmptyStateBox( icon = R.drawable.ic_kid_star, title = "No results yet." ) } - is SearchResultsState.Results -> { + SearchUiState.RESULTS -> { Column { numResultsHeader?.invoke() LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - items(state.items) { item -> + items(filteredResults) { item -> when (item) { is HighlightData.Video -> VideoHighlightCard(item.data, true) diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardRow.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardRow.kt index 57755f6..72c3d17 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardRow.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsCardRow.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import com.cornellappdev.score.R import com.cornellappdev.score.components.ScorePreview import com.cornellappdev.score.model.HighlightData +import com.cornellappdev.score.screen.HighlightsSubScreenType import com.cornellappdev.score.theme.Style.bodyNormal import com.cornellappdev.score.theme.Style.heading2 import com.cornellappdev.score.util.highlightsList @@ -30,7 +31,8 @@ import com.cornellappdev.score.util.highlightsList @Composable fun HighlightsCardRow( highlightsList: List, - rowHeader: String + rowHeader: String, + toSubScreen: (HighlightsSubScreenType) -> Unit ) { Column( modifier = Modifier.fillMaxWidth() @@ -47,7 +49,7 @@ fun HighlightsCardRow( style = heading2 ) Row( - modifier = Modifier.clickable {/*todo navigation to Today screen*/ }, + modifier = Modifier.clickable { toSubScreen(if (rowHeader == "Today") HighlightsSubScreenType.TODAY else HighlightsSubScreenType.PAST3DAYS) }, verticalAlignment = Alignment.CenterVertically ) { Text( @@ -82,6 +84,6 @@ fun HighlightsCardRow( @Composable private fun HighlightsCardRowPreview() { ScorePreview { - HighlightsCardRow(highlightsList, "Today") + HighlightsCardRow(highlightsList, "Today", {}) } } diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsFilter.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsFilter.kt index ea273ff..2c92cca 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsFilter.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsFilter.kt @@ -27,28 +27,30 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection import com.cornellappdev.score.theme.GrayLight import com.cornellappdev.score.theme.GrayPrimary import com.cornellappdev.score.theme.Stroke import com.cornellappdev.score.theme.Style.bodyNormal import com.cornellappdev.score.theme.White -import com.cornellappdev.score.util.sportList +import com.cornellappdev.score.util.sportSelectionList @Composable private fun HighlightsFilterButton( sport: Sport, - onFilterSelected: (Sport) -> Unit, + onFilterSelected: (SportSelection) -> Unit, isSelected: Boolean = false, ) { OutlinedButton( modifier = Modifier .height(32.dp), border = BorderStroke(width = 1.dp, color = Stroke), - onClick = { onFilterSelected(sport) }, + onClick = { onFilterSelected(SportSelection.SportSelect(sport)) }, shape = RoundedCornerShape(100.dp), colors = outlinedButtonColors( containerColor = if (isSelected) GrayLight else White, - contentColor = GrayPrimary + contentColor = GrayPrimary, + disabledContainerColor = GrayLight ), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), ) { @@ -67,20 +69,29 @@ private fun HighlightsFilterButton( @Composable fun HighlightsFilterRow( - sportList: List, - onFilterSelected: (Sport) -> Unit, + sportList: List, + selectedSport: SportSelection, + onFilterSelected: (SportSelection) -> Unit, ) { LazyRow( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp), verticalAlignment = Alignment.CenterVertically ) { - item { Spacer(Modifier.width(12.dp)) } - items(sportList) { item -> - HighlightsFilterButton(item, onFilterSelected) + items( + items = sportList.filterIsInstance(), + key = { it.sport } + ) { selection -> + + val isSelected = selectedSport == selection + + HighlightsFilterButton( + sport = selection.sport, + onFilterSelected = onFilterSelected, + isSelected = isSelected + ) } - item { Spacer(Modifier.width(12.dp)) } } } @@ -88,11 +99,11 @@ fun HighlightsFilterRow( @Composable private fun HighlightsFilterButtonPreview() { var isSelected by remember { mutableStateOf(false) } - HighlightsFilterButton(Sport.BASEBALL, { isSelected = !isSelected }, isSelected = isSelected) + HighlightsFilterButton(Sport.BASEBALL, { !isSelected }, isSelected = isSelected) } @Preview @Composable private fun HighlightsFilterRowPreview() { - HighlightsFilterRow(sportList, {}) + HighlightsFilterRow(sportSelectionList, SportSelection.All, {}) } diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsLoadingScreen.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsLoadingScreen.kt new file mode 100644 index 0000000..0d10eb2 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsLoadingScreen.kt @@ -0,0 +1,184 @@ +package com.cornellappdev.score.components.highlights + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.score.components.LoadingStateBox +import com.cornellappdev.score.screen.HighlightsSubScreenHeader +import com.cornellappdev.score.theme.GrayStroke +import com.cornellappdev.score.theme.Style.heading1 +import com.cornellappdev.score.theme.Style.heading2 + +@Composable +fun HighlightsLoadingScreen( + topHeader: String, modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = topHeader, + style = heading1, + color = GrayStroke, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Start) + ) + Spacer(modifier = Modifier.height(16.dp)) + LoadingStateBox(100, 40.dp) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(start = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(4) { + LoadingStateBox( + 100, 30.dp, modifier = Modifier.width(85.dp) + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Loading Today...", style = heading2, color = GrayStroke + ) + LoadingStateBox( + 100, 15.dp, modifier = Modifier.width( + 50.dp + ) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(start = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + LoadingStateBox( + 12, 192.dp, modifier = Modifier.width( + 241.dp + ) + ) + LoadingStateBox( + 12, 192.dp, modifier = Modifier.width( + 241.dp + ) + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Loading Past 3 Days...", style = heading2, color = GrayStroke + ) + LoadingStateBox( + 100, 15.dp, modifier = Modifier.width( + 50.dp + ) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(start = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + LoadingStateBox( + 12, 192.dp, modifier = Modifier.width( + 241.dp + ) + ) + LoadingStateBox( + 12, 192.dp, modifier = Modifier.width( + 241.dp + ) + ) + } + } +} + +@Composable +fun SubHighlightsLoadingScreen( + header: String, modifier: Modifier = Modifier +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + HighlightsSubScreenHeader( + header = header, navigateBack = {}) + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingStateBox( + 100, height = 40.dp + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + Row( + modifier = Modifier.padding(start = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(4) { + LoadingStateBox( + 100, 30.dp, modifier = Modifier.width(85.dp) + ) + } + } + Spacer(modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + repeat(4) { + LoadingStateBox( + 12, 192.dp + ) + } + } + } +} + + +@Preview +@Composable +private fun HighlightsLoadingScreenPreview() = + _root_ide_package_.com.cornellappdev.score.components.ScorePreview { + HighlightsLoadingScreen("Loading Highlights...") + } + +@Preview +@Composable +private fun SubHighlightsLoadingScreenPreview() = + _root_ide_package_.com.cornellappdev.score.components.ScorePreview { + SubHighlightsLoadingScreen("Past 3 Days") + } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsScreenSearchFilterBar.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsScreenSearchFilterBar.kt index d5344b8..130dd74 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsScreenSearchFilterBar.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsScreenSearchFilterBar.kt @@ -10,17 +10,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.score.components.ScorePreview -import com.cornellappdev.score.model.Sport -import com.cornellappdev.score.util.sportList +import com.cornellappdev.score.model.SportSelection +import com.cornellappdev.score.util.sportSelectionList @Composable fun HighlightsScreenSearchFilterBar( - sportList: List + sportList: List, + query: String, + selectedSport: SportSelection, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onFilterSelected: (SportSelection) -> Unit, + navigateBack: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth()) { - HighlightsSearchBar(modifier = Modifier.padding(horizontal = 24.dp)) + HighlightsSearchBar( + modifier = Modifier.padding(horizontal = 24.dp), + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + navigateBack = navigateBack + ) Spacer(modifier = Modifier.height(16.dp)) - HighlightsFilterRow(sportList, {/*todo on filter selected*/ }) + HighlightsFilterRow(sportList, selectedSport, onFilterSelected) } } @@ -28,6 +40,13 @@ fun HighlightsScreenSearchFilterBar( @Composable private fun HighlightsScreenSearchFilterBarPreview() { ScorePreview { - HighlightsScreenSearchFilterBar(sportList) + HighlightsScreenSearchFilterBar( + sportSelectionList, + "", + SportSelection.All, + {}, + {}, + onFilterSelected = {}, + navigateBack = {}) } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsSearchBar.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsSearchBar.kt index 2297e77..6d1f0c3 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsSearchBar.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/HighlightsSearchBar.kt @@ -12,12 +12,12 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,7 +34,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,19 +45,24 @@ import com.cornellappdev.score.theme.GrayLight import com.cornellappdev.score.theme.Style.bodyMedium import com.cornellappdev.score.theme.Style.bodyNormal -private fun Modifier.highlightsSearchRowModifier(): Modifier = this - .fillMaxWidth() - .background(Color.White, RoundedCornerShape(100.dp)) - .border(1.dp, GrayLight, RoundedCornerShape(100.dp)) - .clip(RoundedCornerShape(100.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp) +private fun Modifier.highlightsSearchRowModifier(): Modifier = + this + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(100.dp)) + .border(1.dp, GrayLight, RoundedCornerShape(100.dp)) + .clip(RoundedCornerShape(100.dp)) + .padding(horizontal = 8.dp, vertical = 8.dp) @Composable fun HighlightsSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, modifier: Modifier = Modifier, + navigateBack: () -> Unit ) { + val keyboardController = LocalSoftwareKeyboardController.current val interactionSource = remember { MutableInteractionSource() } - var searchQuery by remember { mutableStateOf("") } //todo: to be handled by viewmodel var isFocused by remember { mutableStateOf(true) } val focusManager = LocalFocusManager.current @@ -71,42 +78,48 @@ fun HighlightsSearchBar( modifier = modifier ) { BasicTextField( - value = searchQuery, - onValueChange = { searchQuery = it /*todo viewmodel load results*/ }, + value = query, + onValueChange = onQueryChange, singleLine = true, textStyle = bodyNormal, visualTransformation = VisualTransformation.None, interactionSource = interactionSource, enabled = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + onSearch(query) + keyboardController?.hide() + focusManager.clearFocus() + }), modifier = Modifier .focusRequester(focusRequester) .weight(1f) .background(Color.Transparent) .onFocusChanged { focusState -> - isFocused = focusState.isFocused /* todo - consider making this an onFocus function in VM */ + isFocused = + focusState.isFocused /* todo - consider making this an onFocus function in VM */ }, decorationBox = { innerTextField -> Row( - modifier = - Modifier - .highlightsSearchRowModifier() - .padding(horizontal = 8.dp), + modifier = Modifier.highlightsSearchRowModifier(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( painter = painterResource(R.drawable.search), contentDescription = "search icon", tint = Color.Unspecified ) - Spacer(Modifier.width(8.dp)) Box { innerTextField() - if (searchQuery.isEmpty()) { + if (query.isEmpty()) { Text( text = "Search keywords", style = bodyNormal.copy(color = Color.Gray) @@ -116,7 +129,7 @@ fun HighlightsSearchBar( } AnimatedVisibility( - visible = searchQuery.isNotEmpty(), + visible = query.isNotEmpty(), enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { @@ -124,27 +137,22 @@ fun HighlightsSearchBar( painter = painterResource(R.drawable.ic_close), contentDescription = "clear field", modifier = Modifier.clickable( - onClick = { searchQuery = "" } - ) - ) + onClick = { onQueryChange("") })) } } - } - ) + }) AnimatedVisibility( isFocused ) { Text( - "Cancel", - style = bodyMedium, - modifier = Modifier.clickable { - isFocused = false; - focusManager.clearFocus(force = true); - searchQuery = "" - /*todo: clear the text in the search bar*/ - } - ) + "Cancel", style = bodyMedium, modifier = Modifier.clickable( + onClick = { + isFocused = false + focusManager.clearFocus(force = true) + onQueryChange("") + navigateBack() + })) } } } @@ -152,18 +160,14 @@ fun HighlightsSearchBar( /*HighlightsSearchEntryPointRow is the non-functional version of the HighlightsSearchBar, it's a dummy component that's clickable in HighlightsScreen and will navigate to HighlightsSearchScreen */ @Composable fun HighlightsSearchEntryPointRow( - onClick: () -> Unit, - modifier: Modifier = Modifier + onClick: () -> Unit, modifier: Modifier = Modifier ) { - Row( - modifier = - Modifier - .highlightsSearchRowModifier() - .clickable { onClick() } - .then(modifier), + Row(modifier = Modifier + .highlightsSearchRowModifier() + .clickable { onClick() } + .then(modifier), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon( painter = painterResource(R.drawable.search), contentDescription = "search icon", @@ -171,8 +175,7 @@ fun HighlightsSearchEntryPointRow( ) Text( - text = "Search keywords", - style = bodyNormal.copy(color = Color.Gray) + text = "Search keywords", style = bodyNormal.copy(color = Color.Gray) ) } } @@ -186,5 +189,5 @@ private fun HighlightsSearchEntryPointRowPreview() { @Preview @Composable private fun HighlightsSearchBarPreview() { - HighlightsSearchBar() + HighlightsSearchBar(query = "", onQueryChange = {}, onSearch = {}, navigateBack = {}) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/RecentSearches.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/RecentSearches.kt index 460c37a..123bcc2 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/RecentSearches.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/RecentSearches.kt @@ -25,15 +25,15 @@ import com.cornellappdev.score.theme.Style.metricSmallNormal @Composable private fun RecentSearchItem( query: String, - onItemClick: () -> Unit, - onCloseClick: () -> Unit + onItemClick: (String) -> Unit, + onCloseClick: (String) -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .clickable(onClick = { onItemClick() /*search the query*/ }) + .clickable(onClick = { onItemClick(query) /*search the query*/ }) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -48,7 +48,7 @@ private fun RecentSearchItem( } IconButton( - onClick = { onCloseClick() /*delete this search from the recent searches list*/ }, + onClick = { onCloseClick(query) /*delete this search from the recent searches list*/ }, modifier = Modifier.size(10.dp) ) { Icon( @@ -63,8 +63,8 @@ private fun RecentSearchItem( @Composable fun RecentSearches( recentQueriesList: List, - onItemClick: () -> Unit, - onCloseClick: () -> Unit + onItemClick: (String) -> Unit, + onCloseClick: (String) -> Unit ) { Column( modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/score/components/highlights/VideoHighlightsCard.kt b/app/src/main/java/com/cornellappdev/score/components/highlights/VideoHighlightsCard.kt index 44c4efb..a4f3714 100644 --- a/app/src/main/java/com/cornellappdev/score/components/highlights/VideoHighlightsCard.kt +++ b/app/src/main/java/com/cornellappdev/score/components/highlights/VideoHighlightsCard.kt @@ -52,7 +52,9 @@ private fun VideoHighlightCardHeader( AsyncImage( model = imageUrl, contentDescription = "Highlight article image", - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() ) Box( modifier = Modifier @@ -94,17 +96,27 @@ fun VideoHighlightCardBody( Row( verticalAlignment = Alignment.CenterVertically ) { - Icon( - painter = painterResource(videoHighlight.sport.emptyIcon), - contentDescription = "Sport icon", - modifier = Modifier.size(24.dp), - tint = Color.Unspecified - ) - Icon( - painter = painterResource(if (videoHighlight.gender == GenderDivision.FEMALE) R.drawable.ic_gender_women else R.drawable.ic_gender_men), - contentDescription = "Gender icon", - tint = Color.Unspecified - ) + videoHighlight.sport?.let { sport -> + Icon( + painter = painterResource(sport.emptyIcon), + contentDescription = "Sport icon", + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + } + + videoHighlight.gender?.let { gender -> + val iconRes = when (gender) { + GenderDivision.FEMALE -> R.drawable.ic_gender_women + else -> R.drawable.ic_gender_men + } + + Icon( + painter = painterResource(iconRes), + contentDescription = "Gender icon", + tint = Color.Unspecified + ) + } } } Spacer(Modifier.height(8.dp)) @@ -123,7 +135,7 @@ fun VideoHighlightCardBody( } Text( style = labelsNormal, - text = videoHighlight.date + text = videoHighlight.dateString ) } } @@ -157,17 +169,21 @@ class VideoHighlightsPreviewProvider : PreviewParameterProvider>>(ApiResponse.Loading) + val highlightsFlow = _highlightsFlow.asStateFlow() + + + /** + * Asynchronously fetches the list of highlights from the API. Once finished, will send down + * `upcomingGamesFlow` to be observed. + */ + fun fetchHighlights() = appScope.launch { + _highlightsFlow.value = ApiResponse.Loading + try { + val result = + withTimeout(TIMEOUT_TIME_MILLIS) { + apolloClient.query(HighlightsQuery()).execute().toResult() + } + + if (result.isSuccess) { + val highlights = result.getOrNull() + val highlightsList = + highlights?.articles.orEmpty().mapNotNull { it?.toHighlightData() } + + highlights?.youtubeVideos.orEmpty().mapNotNull { it?.toHighlightData() } + + _highlightsFlow.value = + ApiResponse.Success(highlightsList) + + } else { + _highlightsFlow.value = ApiResponse.Error + } + + } catch (e: TimeoutCancellationException) { + Log.e("HighlightsRepository", "Highlights request timed out", e) + _highlightsFlow.value = ApiResponse.Error + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("HighlightsRepository", "Error fetching highlights", e) + _highlightsFlow.value = ApiResponse.Error + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/nav/ScoreNavHost.kt b/app/src/main/java/com/cornellappdev/score/nav/ScoreNavHost.kt index 7002312..0481a2e 100644 --- a/app/src/main/java/com/cornellappdev/score/nav/ScoreNavHost.kt +++ b/app/src/main/java/com/cornellappdev/score/nav/ScoreNavHost.kt @@ -14,11 +14,10 @@ import com.cornellappdev.score.nav.root.ScoreScreens.Home import com.cornellappdev.score.screen.GameDetailsScreen import com.cornellappdev.score.screen.HighlightsScreen import com.cornellappdev.score.screen.HighlightsSearchScreen +import com.cornellappdev.score.screen.HighlightsSubScreen +import com.cornellappdev.score.screen.HighlightsSubScreenType import com.cornellappdev.score.screen.HomeScreen import com.cornellappdev.score.screen.PastGamesScreen -import com.cornellappdev.score.util.highlightsList -import com.cornellappdev.score.util.recentSearchList -import com.cornellappdev.score.util.sportList import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json @@ -68,22 +67,48 @@ fun ScoreNavHost(navController: NavHostController) { }) } - composable { backStackEntry -> + composable { CompositionLocalProvider(LocalViewModelStoreOwner provides mainScreenViewModelStoreOwner) { - HighlightsScreen(toSearchScreen = { navController.navigate(ScoreScreens.HighlightsSearchScreen) }) + HighlightsScreen( + toSearchScreen = { + navController.navigate( + ScoreScreens.HighlightsSearchScreen( + HighlightsSubScreenType.ALL + ) + ) + }, + toSubScreen = { subScreenType -> + navController.navigate(ScoreScreens.HighlightsSubScreen(subScreenType)) + }) } } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val subScreenType = route.subScreenType + + HighlightsSubScreen( + toSearchScreen = { + navController.navigate( + ScoreScreens.HighlightsSearchScreen(subScreenType) + ) + }, + navigateBack = { navController.popBackStack() }, + subScreenType = subScreenType + ) + } + composable { backStackEntry -> CompositionLocalProvider(LocalViewModelStoreOwner provides mainScreenViewModelStoreOwner) { + + val route = + backStackEntry.toRoute() + + val searchScreenType = route.subScreenType + HighlightsSearchScreen( - sportList = sportList, - recentSearchList = recentSearchList, - highlightsList = highlightsList, - query = "", - header = "Search all highlights", - {}, - {} + searchScreenType = searchScreenType, + navigateBack = { navController.popBackStack() } ) } } 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 b74f870..c727d18 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 @@ -28,6 +28,7 @@ import androidx.navigation.toRoute import com.cornellappdev.score.R import com.cornellappdev.score.nav.ScoreNavHost import com.cornellappdev.score.nav.ScoreNavigationBar +import com.cornellappdev.score.screen.HighlightsSubScreenType import com.cornellappdev.score.theme.LocalInfiniteLoading import com.cornellappdev.score.theme.White import kotlinx.serialization.Serializable @@ -114,7 +115,14 @@ sealed class ScoreScreens { data object HighlightsScreen : ScoreScreens() @Serializable - data object HighlightsSearchScreen : ScoreScreens() + data class HighlightsSubScreen( + val subScreenType: HighlightsSubScreenType + ) : ScoreScreens() + + @Serializable + data class HighlightsSearchScreen( + val subScreenType: HighlightsSubScreenType + ) : ScoreScreens() } fun NavBackStackEntry.toScreen(): ScoreScreens? = @@ -124,7 +132,8 @@ fun NavBackStackEntry.toScreen(): ScoreScreens? = "ScoresScreen" -> toRoute() "GameScoreSummaryPage" -> toRoute() "HighlightsScreen" -> toRoute() - "HighlightsSearchScreen" -> toRoute() + "HighlightsSearchScreen" -> toRoute() + "HighlightsSubScreen" -> toRoute() else -> throw IllegalArgumentException("Invalid screen") } diff --git a/app/src/main/java/com/cornellappdev/score/screen/HighlightsScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/HighlightsScreen.kt index c3e30b0..e4eed15 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HighlightsScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HighlightsScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -14,31 +16,88 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.score.R import com.cornellappdev.score.components.EmptyStateBox +import com.cornellappdev.score.components.ErrorState +import com.cornellappdev.score.components.highlights.HighlightsLoadingScreen +import com.cornellappdev.score.components.LoadingScreen import com.cornellappdev.score.components.ScorePreview +import com.cornellappdev.score.components.ScorePullToRefreshBox import com.cornellappdev.score.components.highlights.HighlightsCardRow import com.cornellappdev.score.components.highlights.HighlightsFilterRow import com.cornellappdev.score.components.highlights.HighlightsSearchEntryPointRow +import com.cornellappdev.score.model.ApiResponse import com.cornellappdev.score.model.HighlightData -import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection import com.cornellappdev.score.theme.Style.heading1 import com.cornellappdev.score.util.highlightsList -import com.cornellappdev.score.util.sportList +import com.cornellappdev.score.util.sportSelectionList +import com.cornellappdev.score.viewmodel.HighlightsViewModel +import kotlinx.serialization.Serializable @Composable fun HighlightsScreen( - sportList: List = emptyList(), //note - emptyLists are placeholders for nav to work, will replace will viewModel - todayHighlightsList: List = emptyList(), - pastThreeHighlightsList: List = emptyList(), - toSearchScreen: () -> Unit + highlightsViewModel: HighlightsViewModel = hiltViewModel(), + toSearchScreen: () -> Unit, + toSubScreen: (HighlightsSubScreenType) -> Unit ) { + val uiState = highlightsViewModel.collectUiStateValue() + Column( modifier = Modifier .fillMaxSize() .background(color = Color.White) + .padding(top = 24.dp) + ) { + when (uiState.loadedState) { + is ApiResponse.Loading -> { + HighlightsLoadingScreen("Loading Highlights...") + } + + is ApiResponse.Error -> { + ErrorState({ highlightsViewModel.onRefresh() }, "Oops! Highlights failed to load.") + } + + is ApiResponse.Success -> { + ScorePullToRefreshBox( + isRefreshing = uiState.isRefreshing, + { highlightsViewModel.onRefresh() } + ) { + HighlightsScreenContent( + selectedSport = uiState.sportSelect, + sportList = uiState.sportSelectionList, + onSportSelected = { highlightsViewModel.onSportSelected(it) }, + todayHighlightsList = uiState.todayHighlights, + pastThreeHighlightsList = uiState.pastThreeDaysHighlights, + toSearchScreen = toSearchScreen, + toSubScreen = toSubScreen + ) + } + } + } + } +} + +@Serializable +enum class HighlightsSubScreenType { + TODAY, PAST3DAYS, ALL +} + +@Composable +private fun HighlightsScreenContent( + selectedSport: SportSelection, + onSportSelected: (SportSelection) -> Unit, + sportList: List, + todayHighlightsList: List, + pastThreeHighlightsList: List, + toSearchScreen: () -> Unit, + toSubScreen: (HighlightsSubScreenType) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) ) { - Spacer(modifier = Modifier.height(24.dp)) Column( modifier = Modifier.padding(horizontal = 24.dp) ) { @@ -47,7 +106,7 @@ fun HighlightsScreen( HighlightsSearchEntryPointRow(toSearchScreen) } Spacer(modifier = Modifier.height(16.dp)) - HighlightsFilterRow(sportList, { /*todo: handle with viewmodel*/ }) + HighlightsFilterRow(sportList, selectedSport, onSportSelected) Spacer(modifier = Modifier.height(24.dp)) if (todayHighlightsList.isEmpty() && pastThreeHighlightsList.isEmpty()) { EmptyStateBox( @@ -56,25 +115,26 @@ fun HighlightsScreen( ) } if (todayHighlightsList.isNotEmpty()) { - HighlightsCardRow(todayHighlightsList, "Today") + HighlightsCardRow(todayHighlightsList, "Today", toSubScreen) } if (pastThreeHighlightsList.isNotEmpty()) { - HighlightsCardRow(pastThreeHighlightsList, "Past 3 days") + HighlightsCardRow(pastThreeHighlightsList, "Past 3 days", toSubScreen) } } } + data class HighlightsScreenPreviewData( - val sportList: List, + val sportList: List, val todayHighlightList: List, val pastHighlightList: List ) class HighlightsScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequence { - yield(HighlightsScreenPreviewData(sportList, highlightsList, highlightsList)) - yield(HighlightsScreenPreviewData(sportList, emptyList(), emptyList())) - yield(HighlightsScreenPreviewData(sportList, emptyList(), highlightsList)) + yield(HighlightsScreenPreviewData(sportSelectionList, highlightsList, highlightsList)) + yield(HighlightsScreenPreviewData(sportSelectionList, emptyList(), emptyList())) + yield(HighlightsScreenPreviewData(sportSelectionList, emptyList(), highlightsList)) } } @@ -84,11 +144,13 @@ private fun HighlightScreenPreview( @PreviewParameter(HighlightsScreenPreviewProvider::class) previewData: HighlightsScreenPreviewData ) { ScorePreview { - HighlightsScreen( + HighlightsScreenContent( + selectedSport = SportSelection.All, sportList = previewData.sportList, todayHighlightsList = previewData.todayHighlightList, pastThreeHighlightsList = previewData.pastHighlightList, - toSearchScreen = {} - ) + toSearchScreen = {}, + onSportSelected = {}, + toSubScreen = {}) } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/HighlightsSearchScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/HighlightsSearchScreen.kt index 0fb0bc8..c297083 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HighlightsSearchScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HighlightsSearchScreen.kt @@ -8,36 +8,70 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.score.components.ScorePreview import com.cornellappdev.score.components.highlights.HighlightsCardLazyColumn import com.cornellappdev.score.components.highlights.HighlightsCardLazyColumnResultsHeader import com.cornellappdev.score.components.highlights.HighlightsScreenSearchFilterBar import com.cornellappdev.score.model.HighlightData -import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection import com.cornellappdev.score.theme.Style.heading2 import com.cornellappdev.score.util.highlightsList import com.cornellappdev.score.util.recentSearchList -import com.cornellappdev.score.util.sportList +import com.cornellappdev.score.util.sportSelectionList +import com.cornellappdev.score.viewmodel.HighlightsViewModel + @Composable fun HighlightsSearchScreen( - sportList: List, + highlightsViewModel: HighlightsViewModel = hiltViewModel(), + navigateBack: () -> Unit, + searchScreenType: HighlightsSubScreenType +) { + val uiState = highlightsViewModel.collectUiStateValue() + + val header = when (searchScreenType) { + HighlightsSubScreenType.TODAY -> "Search today" + HighlightsSubScreenType.PAST3DAYS -> "Search past 3 days" + HighlightsSubScreenType.ALL -> "Search all highlights" + } + + HighlightsSearchScreenContent( + sportList = uiState.sportSelectionList, + onFilterSelected = { highlightsViewModel.onSportSelected(it) }, + recentSearchList = uiState.recentSearches, + filteredResults = uiState.filteredHighlights, + query = uiState.query, + selectedFilter = uiState.sportSelect, + onQueryChange = { highlightsViewModel.onQueryChange(it) }, + onSearch = { highlightsViewModel.onSearch(it) }, + header = header, + onItemClick = { highlightsViewModel.onSearchRecent(it) }, + onCloseClick = { highlightsViewModel.onRemoveRecent(it) }, + navigateBack = navigateBack + ) +} + +@Composable +fun HighlightsSearchScreenContent( + sportList: List, + onFilterSelected: (SportSelection) -> Unit, recentSearchList: List, - highlightsList: List, + filteredResults: List, query: String, + selectedFilter: SportSelection, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, header: String, - onItemClick: () -> Unit, - onCloseClick: () -> Unit + onItemClick: (String) -> Unit, + onCloseClick: (String) -> Unit, + navigateBack: () -> Unit ) { Column( modifier = Modifier @@ -49,29 +83,35 @@ fun HighlightsSearchScreen( Spacer(modifier = Modifier.height(16.dp)) HighlightsScreenSearchFilterBar( - sportList + sportList, + query, + selectedFilter, + onQueryChange, + onSearch, + onFilterSelected, + navigateBack ) Spacer(modifier = Modifier.height(24.dp)) HighlightsCardLazyColumn( recentSearchList, query, - highlightsList, onItemClick, onCloseClick, + filteredResults, + onItemClick, + onCloseClick, { HighlightsCardLazyColumnResultsHeader(highlightsList.size) }) } } data class HighlightsSearchScreenPreviewData( - val sportList: List, - val recentSearchList: List, - val query: String + val sportList: List, val recentSearchList: List, val query: String ) class HighlightsSearchScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequence { - yield(HighlightsSearchScreenPreviewData(sportList, recentSearchList, "")) - yield(HighlightsSearchScreenPreviewData(sportList, recentSearchList, "Sports")) - yield(HighlightsSearchScreenPreviewData(sportList, recentSearchList, "Hockey")) + yield(HighlightsSearchScreenPreviewData(sportSelectionList, recentSearchList, "")) + yield(HighlightsSearchScreenPreviewData(sportSelectionList, recentSearchList, "Sports")) + yield(HighlightsSearchScreenPreviewData(sportSelectionList, recentSearchList, "Hockey")) } } @@ -81,14 +121,18 @@ private fun HighlightScreenPreview( @PreviewParameter(HighlightsSearchScreenPreviewProvider::class) previewData: HighlightsSearchScreenPreviewData ) { ScorePreview { - HighlightsSearchScreen( + HighlightsSearchScreenContent( sportList = previewData.sportList, + onFilterSelected = {}, recentSearchList = previewData.recentSearchList, - highlightsList = highlightsList, + filteredResults = highlightsList, query = previewData.query, + selectedFilter = SportSelection.All, + onQueryChange = {}, + onSearch = {}, header = "Search All Highlights", onItemClick = {}, - onCloseClick = {} - ) + onCloseClick = {}, + navigateBack = {}) } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/screen/HighlightsSubScreen.kt b/app/src/main/java/com/cornellappdev/score/screen/HighlightsSubScreen.kt index 2ec4a40..3e309b4 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HighlightsSubScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HighlightsSubScreen.kt @@ -3,18 +3,21 @@ package com.cornellappdev.score.screen 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.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.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.Surface 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.dropShadow import androidx.compose.ui.graphics.Color @@ -24,20 +27,27 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.score.R +import com.cornellappdev.score.components.ErrorState import com.cornellappdev.score.components.ScorePreview -import com.cornellappdev.score.components.highlights.HighlightsCardLazyColumn -import com.cornellappdev.score.components.highlights.HighlightsScreenSearchFilterBar +import com.cornellappdev.score.components.ScorePullToRefreshBox +import com.cornellappdev.score.components.highlights.ArticleHighlightCard +import com.cornellappdev.score.components.highlights.HighlightsFilterRow +import com.cornellappdev.score.components.highlights.HighlightsSearchEntryPointRow +import com.cornellappdev.score.components.highlights.SubHighlightsLoadingScreen +import com.cornellappdev.score.components.highlights.VideoHighlightCard +import com.cornellappdev.score.model.ApiResponse import com.cornellappdev.score.model.HighlightData -import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection import com.cornellappdev.score.theme.Style.heading2 import com.cornellappdev.score.theme.White import com.cornellappdev.score.util.highlightsList -import com.cornellappdev.score.util.recentSearchList -import com.cornellappdev.score.util.sportList +import com.cornellappdev.score.util.sportSelectionList +import com.cornellappdev.score.viewmodel.HighlightsViewModel @Composable -private fun HighlightsSubScreenHeader( +fun HighlightsSubScreenHeader( header: String, navigateBack: () -> Unit ) { @@ -54,18 +64,19 @@ private fun HighlightsSubScreenHeader( ), color = White ) { - Row( + Box( modifier = Modifier .padding(horizontal = 24.dp, vertical = 12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(96.dp) + .fillMaxWidth() ) { Icon( painter = painterResource(R.drawable.ic_left_arrowhead), contentDescription = "back arrow", - modifier = Modifier.clickable(onClick = { navigateBack() }) + modifier = Modifier + .clickable(onClick = { navigateBack() }) + .align(Alignment.CenterStart) ) - Text(header, style = heading2) + Text(header, style = heading2, modifier = Modifier.align(Alignment.Center)) } } } @@ -80,44 +91,115 @@ private fun HighlightsSubScreenHeaderPreview() { @Composable fun HighlightsSubScreen( - sportList: List, - recentSearchList: List, + highlightsViewModel: HighlightsViewModel = hiltViewModel(), + navigateBack: () -> Unit, + toSearchScreen: () -> Unit, + subScreenType: HighlightsSubScreenType +) { + val uiState = highlightsViewModel.collectUiStateValue() + + Column( + modifier = Modifier + .fillMaxSize() + .background(color = Color.White) + ) { + val (highlightsList, header) = when (subScreenType) { + HighlightsSubScreenType.TODAY -> + uiState.todayHighlights to "Today" + + HighlightsSubScreenType.PAST3DAYS -> + uiState.pastThreeDaysHighlights to "Past 3 Days" + + HighlightsSubScreenType.ALL -> + uiState.filteredHighlights to "All highlights" + } + + when (uiState.loadedState) { + is ApiResponse.Loading -> { + SubHighlightsLoadingScreen(header) + } + + is ApiResponse.Error -> { + ErrorState({ highlightsViewModel.onRefresh() }, "Oops! Highlights failed to load.") + } + + is ApiResponse.Success -> { + ScorePullToRefreshBox( + isRefreshing = uiState.loadedState == ApiResponse.Loading, + { highlightsViewModel.onRefresh() } + ) { + HighlightsSubScreenContent( + selectedSport = uiState.sportSelect, + sportList = uiState.sportSelectionList, + onFilterSelected = { highlightsViewModel.onSportSelected(it) }, + highlightsList = highlightsList, + header = header, + navigateBack = navigateBack, + toSearchScreen = toSearchScreen + ) + } + } + } + } +} + +@Composable +fun HighlightsSubScreenContent( + selectedSport: SportSelection, + sportList: List, + onFilterSelected: (SportSelection) -> Unit, highlightsList: List, - query: String, header: String, - onItemClick: () -> Unit, - onCloseClick: () -> Unit + navigateBack: () -> Unit, + toSearchScreen: () -> Unit, ) { Column( modifier = Modifier .fillMaxSize() .background(color = Color.White) - .padding(top = 24.dp) + //.padding(top = 24.dp) ) { - HighlightsSubScreenHeader(header, {}) + HighlightsSubScreenHeader(header, navigateBack) + Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + HighlightsSearchEntryPointRow({ toSearchScreen() }) + } Spacer(modifier = Modifier.height(16.dp)) - HighlightsScreenSearchFilterBar( - sportList + HighlightsFilterRow( + sportList, + selectedSport = selectedSport, + onFilterSelected = onFilterSelected ) + + Spacer(modifier = Modifier.height(24.dp)) - HighlightsCardLazyColumn( - recentSearchList, - query, - highlightsList, - onItemClick, - onCloseClick - ) + LazyColumn( + Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(highlightsList) { item -> + when (item) { + is HighlightData.Video -> + VideoHighlightCard(item.data, true) + + is HighlightData.Article -> + ArticleHighlightCard(item.data, true) + } + } + } } } @Preview @Composable private fun HighlightsSubScreenPreview() { - HighlightsSubScreen( - sportList = sportList, - recentSearchList = recentSearchList, + HighlightsSubScreenContent( + selectedSport = SportSelection.All, + sportList = sportSelectionList, + onFilterSelected = {}, highlightsList = highlightsList, - query = "s", header = "Past 3 Days", {}, {} ) 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 65b7da5..ecdbbdf 100644 --- a/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/score/screen/HomeScreen.kt @@ -74,7 +74,7 @@ fun HomeScreen( } is ApiResponse.Error -> { - ErrorState({ homeViewModel.onRefresh() }, "Oops! Schedules failed to load.") + ErrorState({homeViewModel.onRefresh() }, "Oops! Schedules failed to load.") } is ApiResponse.Success -> { diff --git a/app/src/main/java/com/cornellappdev/score/util/DateUtil.kt b/app/src/main/java/com/cornellappdev/score/util/DateUtil.kt index 3075fe7..a76544c 100644 --- a/app/src/main/java/com/cornellappdev/score/util/DateUtil.kt +++ b/app/src/main/java/com/cornellappdev/score/util/DateUtil.kt @@ -2,8 +2,10 @@ package com.cornellappdev.score.util import android.util.Log import java.time.Duration +import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale @@ -66,6 +68,21 @@ fun parseDateTimeOrNull(date: String, time: String): LocalDateTime? { } } +/** + * Parses an ISO-8601 timestamp: yyyy-MM-dd'T'HH:mm:ss'Z' into a LocalDate? object. + * + * @param strDate the date string to parse, in the format "yyyy-MM-dd'T'HH:mm:ss'Z'" + * @return a LocalDate object if parsing succeeds, or null if the format is invalid + */ +fun parseIsoDateToLocalDateOrNull(strDate: String): LocalDate? { + return try { + Instant.parse(strDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } catch (e: Exception) { + null + } +} /** * Formats a date and time string into a user-friendly display string. 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 476077b..8018e54 100644 --- a/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt +++ b/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt @@ -234,15 +234,18 @@ val highlightsList = listOf( "vs Columbia", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/09", Sport.BASEBALL, - GenderDivision.MALE + GenderDivision.MALE, + "0:44" )), HighlightData.Article (ArticleHighlightData( "Late Goal Lifts No. 6 Men’s Hockey Over Brown", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/09", Sport.ICE_HOCKEY )), @@ -251,15 +254,18 @@ val highlightsList = listOf( "vs Columbia", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/9", Sport.BASEBALL, - GenderDivision.MALE + GenderDivision.MALE, + "0:44" )), HighlightData.Article (ArticleHighlightData( "Late Goal Lifts No. 6 Men’s Hockey Over Brown", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/09", Sport.ICE_HOCKEY )), @@ -268,8 +274,10 @@ val highlightsList = listOf( "vs Columbia", "maxresdefault.jpg", "https://cornellsun.com/article/london-mcdavid-is-making-a-name-for-herself-at-cornell", + null, "11/9", Sport.BASEBALL, - GenderDivision.MALE + GenderDivision.MALE, + "0:44" )) ) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/score/viewmodel/HighlightsViewModel.kt b/app/src/main/java/com/cornellappdev/score/viewmodel/HighlightsViewModel.kt new file mode 100644 index 0000000..e5e26af --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/viewmodel/HighlightsViewModel.kt @@ -0,0 +1,219 @@ +package com.cornellappdev.score.viewmodel + +import com.cornellappdev.score.model.ApiResponse +import com.cornellappdev.score.model.GenderDivision +import com.cornellappdev.score.model.HighlightData +import com.cornellappdev.score.model.HighlightsRepository +import com.cornellappdev.score.model.Sport +import com.cornellappdev.score.model.SportSelection +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import java.time.LocalDate +import javax.inject.Inject + +data class HighlightsUiState( + val sportSelect: SportSelection, + val loadedState: ApiResponse>, + val isRefreshing: Boolean, + val sportSelectionList: List, + val filteredHighlights: List, + val todayHighlights: List, + val pastThreeDaysHighlights: List, + val query: String, + val recentSearches: List +) + +private fun buildDerivedLists( + highlights: List, + sportSelect: SportSelection, + query: String +): Triple, List, List> { + val today = LocalDate.now() + val threeDaysAgo = today.minusDays(3) + + val filteredBySport = highlights + .filter { it.date != null } + .filter { h -> + when (sportSelect) { + is SportSelection.All -> true + is SportSelection.SportSelect -> h.sport == sportSelect.sport + } + } + + val filteredByQuery = + if (query.isBlank()) filteredBySport + else filteredBySport.filter { + it.title.contains(query, ignoreCase = true) || + (it.sport?.displayName?.contains(query, ignoreCase = true) == true) + } + + val sorted = filteredByQuery.sortedByDescending { it.date } + + val todayHighlights = sorted.filter { it.date == today } + + val pastThreeDays = sorted.filter { date -> + date.date?.let { + it < today && it >= threeDaysAgo + } ?: false + } + + return Triple(sorted, todayHighlights, pastThreeDays) +} + +private fun recompute(highlights: List, state: HighlightsUiState) + : HighlightsUiState { + + val (filtered, today, pastThreeDays) = + buildDerivedLists(highlights, state.sportSelect, state.query) + + return state.copy( + filteredHighlights = filtered, + todayHighlights = today, + pastThreeDaysHighlights = pastThreeDays + ) +} + +@HiltViewModel +class HighlightsViewModel @Inject constructor( + private val highlightsRepository: HighlightsRepository +) : BaseViewModel( + HighlightsUiState( + sportSelect = SportSelection.All, + loadedState = ApiResponse.Loading, + isRefreshing = true, + sportSelectionList = Sport.getSportSelectionList(GenderDivision.ALL), + filteredHighlights = emptyList(), + todayHighlights = emptyList(), + pastThreeDaysHighlights = emptyList(), + query = "", + recentSearches = emptyList() + ) +) { + init { + highlightsRepository.fetchHighlights() + asyncCollect(highlightsRepository.highlightsFlow) { response -> + applyMutation { + when (response) { + is ApiResponse.Success -> { + val sorted = response.data.sortedByDescending { it.date } + + val (filtered, today, pastThreeDays) = + buildDerivedLists(sorted, sportSelect, query) + + copy( + loadedState = ApiResponse.Success(sorted), + isRefreshing = false, + filteredHighlights = filtered, + todayHighlights = today, + pastThreeDaysHighlights = pastThreeDays + ) + } + + ApiResponse.Loading -> + if (loadedState is ApiResponse.Success) { + copy( + isRefreshing = true + ) + } else { + copy(isRefreshing = true, + loadedState = ApiResponse.Loading, + filteredHighlights = emptyList(), + todayHighlights = emptyList(), + pastThreeDaysHighlights = emptyList() + ) + } + + ApiResponse.Error -> + if (loadedState is ApiResponse.Success) { + copy( + isRefreshing = false + ) + } else { + copy( + loadedState = ApiResponse.Error, + isRefreshing = false, + filteredHighlights = emptyList(), + todayHighlights = emptyList(), + pastThreeDaysHighlights = emptyList() + ) + } + } + } + } + } + + fun onQueryChange(newQuery: String) { + applyMutation { + val highlights = (loadedState as? ApiResponse.Success)?.data.orEmpty() + + recompute( + highlights, + copy(query = newQuery) + ) + } + } + + fun onRefresh() { + applyMutation { + copy(isRefreshing = true) + } + highlightsRepository.fetchHighlights() + } + + fun onSportSelected(sport: SportSelection) { + applyMutation { + val highlights = + (loadedState as? ApiResponse.Success)?.data.orEmpty() + + val newSelection = + if (sportSelect == sport) { + SportSelection.All + } else { + sport + } + + recompute( + highlights, + copy(sportSelect = newSelection) + ) + } + } + + fun onSearch(query: String) { + if (query.isBlank()) return + + applyMutation { + val highlights = + (loadedState as? ApiResponse.Success)?.data.orEmpty() + + val updatedSearches = + (listOf(query) + recentSearches) + .distinct() + .take(3) + + recompute( + highlights, + copy( + query = query, + recentSearches = updatedSearches + ) + ) + } + } + + //Removes an item from the recent searches list + fun onRemoveRecent(recent: String) { + applyMutation { + val updated = recentSearches.filterNot { it == recent } + copy(recentSearches = updated) + } + } + + //Searches an item from the recent searches list + fun onSearchRecent(recent: String) { + onSearch(recent) + } +}