Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ dependencies {
apollo {
service("service") {
packageName.set("com.example.score")
introspection {
endpointUrl.set("\"${secrets.getProperty("API_URL_DEV")}\"")
schemaFile.set(file("src/main/graphql/schema.graphqls"))
secrets.getProperty("API_URL_DEV")?.takeIf { it.isNotBlank() }?.let { apiUrl ->
introspection {
endpointUrl.set(apiUrl)
schemaFile.set(file("src/main/graphql/schema.graphqls"))
}
}
}
}

51 changes: 22 additions & 29 deletions app/src/main/graphql/FragmentedGame.graphql
Original file line number Diff line number Diff line change
@@ -1,33 +1,26 @@
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
}
...GameListItem
}
}

query InitialGames($startDate: DateTime!, $endDate: DateTime!) {
gamesByDate(startDate: $startDate, endDate: $endDate) {
...GameListItem
}
}

fragment GameListItem on GameType {
id
city
date
gender
result
sport
time
team {
color
image
name
}
}
86 changes: 82 additions & 4 deletions app/src/main/graphql/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ type Query {

game(id: String!): GameType

gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!, ticketLink: String): GameType
gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!): GameType

gamesBySport(sport: String!): [GameType]

gamesByGender(gender: String!): [GameType]

gamesBySportGender(sport: String!, gender: String!): [GameType]

gamesByDate(startDate: DateTime!, endDate: DateTime!): [GameType]

"""
Current user's favorited games (requires auth).
"""
myFavoritedGames: [GameType]

teams: [TeamType]

team(id: String!): TeamType
Expand Down Expand Up @@ -55,9 +62,11 @@ Attributes:
- id: The YouTube video ID (optional).
- title: The title of the video.
- description: The description of the video.
- thumbnail: The URL of the video's thumbnail.
- thumbnail: The URL of the video's thumbnail. (optional)
- 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
Expand All @@ -68,11 +77,15 @@ type YoutubeVideoType {

thumbnail: String!

b64Thumbnail: String!
b64Thumbnail: String

url: String!

publishedAt: String!

duration: String

sportsType: String
}

"""
Expand Down Expand Up @@ -181,6 +194,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.
Expand All @@ -195,12 +215,42 @@ 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.
"""
createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle

"""
Login by net_id; returns access_token and refresh_token.
"""
loginUser("User's net ID (e.g. Cornell netid)." netId: String!): LoginUser

"""
Create a new user by net_id; returns access_token and refresh_token (no separate login needed).
"""
signupUser("Email address." email: String, "Display name." name: String, "User's net ID (e.g. Cornell netid)." netId: String!): SignupUser

"""
Exchange a valid refresh token (in Authorization header) for a new access_token.
"""
refreshAccessToken: RefreshAccessToken

"""
Revoke the current token (access or refresh). Send token in Authorization header.
"""
logoutUser: LogoutUser

"""
Add a game to the current user's favorites (requires auth).
"""
addFavoriteGame("ID of the game to add to favorites." gameId: String!): AddFavoriteGame

"""
Remove a game from the current user's favorites (requires auth).
"""
removeFavoriteGame("ID of the game to remove from favorites." gameId: String!): RemoveFavoriteGame
}

type CreateGame {
Expand All @@ -219,6 +269,34 @@ type CreateArticle {
article: ArticleType
}

type LoginUser {
accessToken: String

refreshToken: String
}

type SignupUser {
accessToken: String

refreshToken: String
}

type RefreshAccessToken {
newAccessToken: String
}

type LogoutUser {
success: Boolean
}

type AddFavoriteGame {
success: Boolean
}

type RemoveFavoriteGame {
success: Boolean
}

"""
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.
"""
Expand Down
66 changes: 55 additions & 11 deletions app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import com.cornellappdev.score.util.isValidSport
import com.cornellappdev.score.util.parseColor
import com.cornellappdev.score.util.parseResultScore
import com.example.score.GameByIdQuery
import com.example.score.InitialGamesQuery
import com.example.score.GamesQuery
import com.example.score.PagedGamesQuery
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -24,15 +29,15 @@ private const val PAGE_TIMEOUT_MILLIS = 3000L

/**
* This is a singleton responsible for fetching and caching all data for Score.
* Right now, it makes a network request for all possible games. In the future,
* we should limit this to games only in a certain time range, to prevent the
* app from slowing down and improve load times.
* Publishes a small date window first, then loads the full game history.
*/
@Singleton
class ScoreRepository @Inject constructor(
private val apolloClient: ApolloClient,
private val appScope: CoroutineScope,
) {
private val gamesFetchMutex = Mutex()

private val _upcomingGamesFlow =
MutableStateFlow<ApiResponse<List<Game>>>(ApiResponse.Loading)
val upcomingGamesFlow = _upcomingGamesFlow.asStateFlow()
Expand Down Expand Up @@ -99,20 +104,44 @@ class ScoreRepository @Inject constructor(
}

fun fetchGames() = appScope.launch {
if (!gamesFetchMutex.tryLock()) return@launch
_upcomingGamesFlow.value = ApiResponse.Loading
val allGames = mutableListOf<Game>()
var offset = 0
var retries = 0
var initialWindow = true

try {
while (true) {
val pageResult = runCatching {
withTimeout(PAGE_TIMEOUT_MILLIS) {
apolloClient.query(
PagedGamesQuery(limit = PAGE_LIMIT, offset = offset)
).execute().data?.games
val pageResult = try {
withTimeoutOrNull(PAGE_TIMEOUT_MILLIS) {
if (initialWindow) {
val today = LocalDate.now()
apolloClient.query(
InitialGamesQuery(
today.atStartOfDay().toString(),
today.plusDays(30).atStartOfDay().toString()
)
).execute().toResult().getOrNull()?.gamesByDate
?.map { it?.gameListItem }
} else {
apolloClient.query(
PagedGamesQuery(limit = PAGE_LIMIT, offset = offset)
).execute().toResult().getOrNull()?.games
?.map { it?.gameListItem }
}
}
}.getOrNull()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}

// A failed or empty date window falls back to the full fetch.
if (initialWindow && pageResult.isNullOrEmpty()) {
initialWindow = false
continue
}

if (pageResult == null) {
if (retries < MAX_RETRIES) {
Expand Down Expand Up @@ -158,17 +187,32 @@ class ScoreRepository @Inject constructor(

allGames.addAll(pageGames)

if (initialWindow) {
if (allGames.isNotEmpty()) {
_upcomingGamesFlow.value = ApiResponse.Success(allGames.toList())
}
initialWindow = false
continue
}

if (pageResult.size < PAGE_LIMIT) break
offset += PAGE_LIMIT
}

_upcomingGamesFlow.value =
if (allGames.isNotEmpty()) ApiResponse.Success(allGames)
if (allGames.isNotEmpty()) ApiResponse.Success(allGames.asReversed().distinctBy { it.id }.asReversed())
else if (_upcomingGamesFlow.value is ApiResponse.Success) _upcomingGamesFlow.value
else ApiResponse.Error

} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e("ScoreRepository", "Error fetching upcoming games", e)
_upcomingGamesFlow.value = ApiResponse.Error
if (_upcomingGamesFlow.value !is ApiResponse.Success) {
_upcomingGamesFlow.value = ApiResponse.Error
}
} finally {
gamesFetchMutex.unlock()
}
}

Expand Down
Loading