diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 006c248..84bdc85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,11 @@ android { "BASE_URL", "\"${secrets.getProperty("API_URL_DEV")}\"" ) + buildConfigField( + "String", + "SOCKET_URL", + "\"${secrets.getProperty("SOCKET_URL_DEV")}\"" + ) } release { buildConfigField( @@ -50,6 +55,11 @@ android { "BASE_URL", "\"${secrets.getProperty("API_URL_PROD")}\"" ) + buildConfigField( + "String", + "SOCKET_URL", + "\"${secrets.getProperty("SOCKET_URL_PROD")}\"" + ) isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -98,6 +108,9 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("io.socket:socket.io-client:2.1.1") { + exclude(group = "org.json", module = "json") + } implementation("com.apollographql.apollo:apollo-runtime:4.0.0") implementation("io.coil-kt.coil3:coil-compose:3.1.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0") diff --git a/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt b/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt index 16d09ef..134dd8b 100644 --- a/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt +++ b/app/src/main/java/com/cornellappdev/score/components/PastGameCard.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.cornellappdev.score.util.CORNELL_TEAM_NAME import com.cornellappdev.score.R import com.cornellappdev.score.model.GameCardData import com.cornellappdev.score.theme.AmbientColor @@ -155,7 +156,7 @@ private fun TeamScore( if (isCornell) { Image( painter = painterResource(R.drawable.cornell_logo), - contentDescription = "Cornell Logo", + contentDescription = "$CORNELL_TEAM_NAME Logo", modifier = Modifier .height(27.dp) .padding(horizontal = 2.dp, vertical = 4.dp) @@ -171,7 +172,7 @@ private fun TeamScore( } Spacer(modifier = Modifier.width(4.dp)) Text( - text = if (isCornell) "Cornell" else team, + text = if (isCornell) CORNELL_TEAM_NAME else team, style = heading2, color = if (winningTeam) GrayPrimary else GrayLight ) diff --git a/app/src/main/java/com/cornellappdev/score/components/ScoreSummary.kt b/app/src/main/java/com/cornellappdev/score/components/ScoreSummary.kt index 68b339c..0b0162d 100644 --- a/app/src/main/java/com/cornellappdev/score/components/ScoreSummary.kt +++ b/app/src/main/java/com/cornellappdev/score/components/ScoreSummary.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.cornellappdev.score.util.CORNELL_TEAM_NAME import com.cornellappdev.score.R import com.cornellappdev.score.components.ScorePreview import com.cornellappdev.score.model.ScoreEvent @@ -100,7 +101,7 @@ fun ScoreEventItem(event: ScoreEvent) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = homeScore.toString(), - style = if (event.team.name == "Cornell") metricSemibold else metricNormal, // TODO: Check name + style = if (event.team.name == CORNELL_TEAM_NAME) metricSemibold else metricNormal, // TODO: Check name color = GrayPrimary, textAlign = TextAlign.Center ) @@ -112,7 +113,7 @@ fun ScoreEventItem(event: ScoreEvent) { ) Text( text = awayScore.toString(), - style = if (event.team.name != "Cornell") metricSemibold else metricNormal, + style = if (event.team.name != CORNELL_TEAM_NAME) metricSemibold else metricNormal, color = GrayPrimary, textAlign = TextAlign.Center ) diff --git a/app/src/main/java/com/cornellappdev/score/model/Game.kt b/app/src/main/java/com/cornellappdev/score/model/Game.kt index 6074d76..672490c 100644 --- a/app/src/main/java/com/cornellappdev/score/model/Game.kt +++ b/app/src/main/java/com/cornellappdev/score/model/Game.kt @@ -1,6 +1,7 @@ package com.cornellappdev.score.model import androidx.compose.ui.graphics.Color +import com.cornellappdev.score.util.CORNELL_TEAM_NAME import com.cornellappdev.score.R import com.cornellappdev.score.util.convertScores import com.cornellappdev.score.util.formatDateTimeDisplay @@ -43,16 +44,17 @@ data class GameDetailsTeam( val name: String ) +@Serializable data class GameDetailsBoxScore( - val team: String?, - val period: String?, - val time: String?, - val description: String?, - val scorer: String?, - val assist: String?, - val scoreBy: String?, - val corScore: Int?, - val oppScore: Int? + val team: String? = null, + val period: String? = null, + val time: String? = null, + val description: String? = null, + val scorer: String? = null, + val assist: String? = null, + val scoreBy: String? = null, + val corScore: Int? = null, + val oppScore: Int? = null ) data class GameDetailsGame( @@ -127,7 +129,8 @@ data class DetailsCardData( val daysUntilGame: Int?, val hoursUntilGame: Int?, val homeScore: Int, - val oppScore: Int + val oppScore: Int, + val result: String = "" ) // Scoring information by round of a game, used in the box score @@ -272,7 +275,7 @@ fun GameDetailsGame.toGameCardData(): DetailsCardData { val (daysUntil, hoursUntil) = getTimeUntilStart(date, time ?: "") ?: (null to null) val parsedScores = parseResultScore(result) return DetailsCardData( - title = "Cornell Vs. ${team?.name ?: ""}", + title = "$CORNELL_TEAM_NAME Vs. ${team?.name ?: ""}", opponentLogo = team?.image ?: "", opponent = team?.name ?: "", opponentColor = team?.color ?: Color.White, @@ -293,7 +296,7 @@ fun GameDetailsGame.toGameCardData(): DetailsCardData { scoreBreakdown = scoreBreakdown ?: emptyList(), gameData = toGameData( scoreBreakdown = scoreBreakdown, - team1 = TeamBoxScore("Cornell"), + team1 = TeamBoxScore(CORNELL_TEAM_NAME), team2 = TeamBoxScore(team?.name ?: ""), sport = sport, result = result ?: "" @@ -304,7 +307,43 @@ fun GameDetailsGame.toGameCardData(): DetailsCardData { homeScore = convertScores(scoreBreakdown?.getOrNull(0), sport, result ?: "").second ?: parsedScores?.first ?: 0, oppScore = convertScores(scoreBreakdown?.getOrNull(1), sport, result ?: "").second - ?: parsedScores?.second ?: 0 + ?: parsedScores?.second ?: 0, + result = result ?: "" + ) +} + +/** + * Merges a live socket update into the current DetailsCardData. + * Null fields in [update] leave existing values unchanged. + */ +fun DetailsCardData.applySocketUpdate(update: SocketGameUpdateData): DetailsCardData { + val newScoreBreakdown = update.scoreBreakdown ?: scoreBreakdown + val newGameData = if (update.scoreBreakdown != null) { + toGameData( + scoreBreakdown = newScoreBreakdown, + team1 = TeamBoxScore(CORNELL_TEAM_NAME), + team2 = TeamBoxScore(opponent), + sport = sport, + result = result + ) + } else gameData + + val newBoxScore = update.boxScore ?: boxScore + val newScoreEvents = if (update.boxScore != null) { + newBoxScore.filterNotNull().toScoreEvents(opponentLogo) + } else scoreEvent + + return copy( + homeScore = update.homeScore + ?: update.scoreBreakdown?.let { convertScores(it.getOrNull(0), sport, result).second } + ?: homeScore, + oppScore = update.oppScore + ?: update.scoreBreakdown?.let { convertScores(it.getOrNull(1), sport, result).second } + ?: oppScore, + scoreBreakdown = newScoreBreakdown, + gameData = newGameData, + boxScore = newBoxScore, + scoreEvent = newScoreEvents ) } @@ -328,4 +367,4 @@ fun List.toScoreEvents(teamLogo: String): List description = boxScore.description ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt b/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt new file mode 100644 index 0000000..a554b8c --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt @@ -0,0 +1,73 @@ +package com.cornellappdev.score.model + +import android.util.Log +import com.cornellappdev.score.BuildConfig +import io.socket.client.IO +import io.socket.client.Socket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.json.JSONObject +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "SocketManager" + +@Singleton +class SocketManager @Inject constructor(private val appScope: CoroutineScope) { + + private val _gameUpdateFlow = + MutableSharedFlow(extraBufferCapacity = 16) + val gameUpdateFlow: SharedFlow = _gameUpdateFlow.asSharedFlow() + + private val activeSubscriptions: MutableSet = + Collections.synchronizedSet(mutableSetOf()) + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val socket: Socket by lazy { + val opts = IO.Options.builder() + .setTransports(arrayOf("websocket")) + .build() + IO.socket(BuildConfig.SOCKET_URL, opts).also { s -> + // "on" is a listener + s.on(Socket.EVENT_CONNECT) { + Log.d(TAG, "Connected") + val subscriptions = synchronized(activeSubscriptions) { + activeSubscriptions.toList() + } + subscriptions.forEach { id -> + s.emit("subscribe", JSONObject().put("gameId", id)) + } + } + s.on(Socket.EVENT_DISCONNECT) { args -> + Log.d( + TAG, + "Disconnected: ${args.firstOrNull()}" + ) + } + s.on(Socket.EVENT_CONNECT_ERROR) { args -> Log.e(TAG, "Error: ${args.firstOrNull()}") } + s.on("game_update") { args -> + val raw = args.firstOrNull() as? JSONObject ?: return@on + runCatching { json.decodeFromString(raw.toString()) } + .onSuccess { appScope.launch { _gameUpdateFlow.emit(it) } } + .onFailure { Log.e(TAG, "Parse error: $it") } + } + s.connect() + } + } + + fun subscribe(gameId: String) { + activeSubscriptions.add(gameId) + socket.emit("subscribe", JSONObject().put("gameId", gameId)) + } + + fun unsubscribe(gameId: String) { + activeSubscriptions.remove(gameId) + socket.emit("unsubscribe", JSONObject().put("gameId", gameId)) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt b/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt new file mode 100644 index 0000000..8355ec6 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt @@ -0,0 +1,19 @@ +package com.cornellappdev.score.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SocketGameUpdateEnvelope( + val type: String, + val gameId: String, + val timestamp: String, + val data: SocketGameUpdateData +) + +@Serializable +data class SocketGameUpdateData( + val homeScore: Int? = null, + val oppScore: Int? = null, + val scoreBreakdown: List?>? = null, + val boxScore: List? = null +) diff --git a/app/src/main/java/com/cornellappdev/score/util/CalendarUtil.kt b/app/src/main/java/com/cornellappdev/score/util/CalendarUtil.kt index 24b02f1..288a498 100644 --- a/app/src/main/java/com/cornellappdev/score/util/CalendarUtil.kt +++ b/app/src/main/java/com/cornellappdev/score/util/CalendarUtil.kt @@ -43,7 +43,7 @@ fun DetailsCardData.toCalendarEvent(): CalendarEvent? { val date = this.date ?: return null return CalendarEvent( - title = "Cornell vs. ${this.opponent}", + title = "$CORNELL_TEAM_NAME vs. ${this.opponent}", description = "${this.sport} game (${this.gender})", location = this.locationString, date = date, diff --git a/app/src/main/java/com/cornellappdev/score/util/TeamConstants.kt b/app/src/main/java/com/cornellappdev/score/util/TeamConstants.kt new file mode 100644 index 0000000..5942358 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/util/TeamConstants.kt @@ -0,0 +1,3 @@ +package com.cornellappdev.score.util + +const val CORNELL_TEAM_NAME = "Cornell" 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..a726a12 100644 --- a/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt +++ b/app/src/main/java/com/cornellappdev/score/util/TestingConstants.kt @@ -58,7 +58,7 @@ val gameList = listOf( PRINCETON_GAME ) -val team1 = TeamBoxScore(name = "Cornell") +val team1 = TeamBoxScore(name = CORNELL_TEAM_NAME) val team2 = TeamBoxScore(name = "Yale University") val teamScore1 = TeamScore( @@ -129,7 +129,7 @@ val longGameData = GameData(teamScores = longGameTeamScore1 to longGameTeamScore val extraLongGameData = GameData(teamScores = extraLongGameTeamScore1 to extraLongGameTeamScore2) val team3 = TeamGameSummary( - name = "Cornell", + name = CORNELL_TEAM_NAME, "https://cornellbigred.com/images/logos/penn_200x200.png?width=80&height=80&mode=max" ) val team4 = TeamGameSummary( diff --git a/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt b/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt index 1b69f30..85f54b0 100644 --- a/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt +++ b/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt @@ -1,14 +1,19 @@ package com.cornellappdev.score.viewmodel import androidx.lifecycle.SavedStateHandle -import androidx.navigation.toRoute +import androidx.lifecycle.viewModelScope import com.cornellappdev.score.model.ApiResponse import com.cornellappdev.score.model.DetailsCardData import com.cornellappdev.score.model.ScoreRepository +import com.cornellappdev.score.model.SocketGameUpdateData +import com.cornellappdev.score.model.SocketManager +import com.cornellappdev.score.model.applySocketUpdate import com.cornellappdev.score.model.map import com.cornellappdev.score.model.toGameCardData -import com.cornellappdev.score.nav.root.ScoreScreens import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import java.time.Instant import javax.inject.Inject data class GameDetailsUiState( @@ -18,6 +23,7 @@ data class GameDetailsUiState( @HiltViewModel class GameDetailsViewModel @Inject constructor( private val scoreRepository: ScoreRepository, + private val socketManager: SocketManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialUiState = GameDetailsUiState( @@ -25,22 +31,55 @@ class GameDetailsViewModel @Inject constructor( ) ) { private val gameId: String = checkNotNull(savedStateHandle["gameId"]) + private val pendingUpdates = mutableListOf() + private var latestUpdateTime: Instant? = null init { asyncCollect(scoreRepository.currentGamesFlow) { response -> + if (response is ApiResponse.Success && response.data.id != gameId) return@asyncCollect applyMutation { copy( loadedState = response.map { gameCard -> - gameCard.toGameCardData() + // Apply every partial update in order so omitted fields aren't lost. + pendingUpdates.fold(gameCard.toGameCardData()) { card, update -> + card.applySocketUpdate(update) + }.also { pendingUpdates.clear() } } ) } } onRefresh() + + // Start listening before subscribing, including an immediate subscription snapshot. + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + socketManager.gameUpdateFlow.collect { envelope -> + if (envelope.gameId != gameId) return@collect + val updateTime = runCatching { Instant.parse(envelope.timestamp) }.getOrNull() + val latestTime = latestUpdateTime + if (updateTime != null && latestTime != null && updateTime.isBefore(latestTime)) { + return@collect + } + if (updateTime != null) latestUpdateTime = updateTime + applyMutation { + val current = loadedState + if (current !is ApiResponse.Success) { + pendingUpdates.add(envelope.data) + return@applyMutation this + } + copy(loadedState = ApiResponse.Success(current.data.applySocketUpdate(envelope.data))) + } + } + } + socketManager.subscribe(gameId) } fun onRefresh() { applyMutation { copy(loadedState = ApiResponse.Loading) } scoreRepository.getGameById(gameId) } -} \ No newline at end of file + + override fun onCleared() { + super.onCleared() + socketManager.unsubscribe(gameId) + } +}