From 3140f9a5718607ee4f8a0739af4f9988f16ea915 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:32:56 -0300 Subject: [PATCH] feat(profile): derive the profile P&L from the real round ledger ProfileViewModel now combines the signed-in identity with GameRepository.rounds, replacing the placeholder P&L and game-activity blocks with values computed from the persisted game_round ledger. The all-time summary (net, bets, win rate, won/wagered) and the per-game activity breakdown (bets, wagered and net per game, ordered by volume) come from the real rounds; with no rounds the screen shows zeros and a "No game activity yet" empty state. This closes the stats layer: history, round detail and the profile now all read real rounds. Covered by the extended ProfileViewModelTest. --- CHANGELOG.md | 10 ++ .../feature/profile/ProfileGameActivity.kt | 15 ++- .../feature/profile/ProfileViewModel.kt | 106 +++++++++++++++--- .../feature/profile/ProfileViewModelTest.kt | 81 ++++++++++++- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7ba0e..595a579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Profile P&L on real rounds (cu-15): `ProfileViewModel` now combines the + signed-in identity with `GameRepository.rounds`, replacing the + placeholder P&L and game-activity blocks. The all-time summary (net, + bets, win rate, won/wagered) and the per-game activity breakdown + (bets, wagered and net per game, ordered by volume) are computed from + the persisted `game_round` ledger; with no rounds it shows zeros and a + "No game activity yet" empty state. Closes the stats layer (history, + round detail and profile now all read real rounds). Covered by the + extended `ProfileViewModelTest`. + - History and Round Detail on real rounds (cu-10 / cu-11): the stats layer wires the persisted `game_round` ledger into the screens that were on preview data. `HistoryViewModel` maps `GameRepository.rounds` diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt index 77f4021..24c1dd2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt @@ -63,9 +63,18 @@ internal fun ProfileGameActivity(rows: List) { } Spacer(modifier = Modifier.height(12.dp)) Column(modifier = Modifier.fillMaxWidth().profileCardSurface()) { - rows.forEachIndexed { index, row -> - if (index > 0) Divider() - GameActivityRowItem(row = row) + if (rows.isEmpty()) { + Text( + text = "No game activity yet", + color = TextLow, + fontSize = 12.sp, + modifier = Modifier.padding(CardPadding), + ) + } else { + rows.forEachIndexed { index, row -> + if (index > 0) Divider() + GameActivityRowItem(row = row) + } } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt index 86169db..c669ec1 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt @@ -4,6 +4,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.plainstudio.stackcasino.domain.auth.AuthRepository import com.plainstudio.stackcasino.domain.auth.AuthUser +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.util.initialsOf import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -11,24 +15,24 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.Locale import javax.inject.Inject /** * Drives the profile screen. * - * Observes [AuthRepository.currentUser] and maps the signed-in user into - * [ProfileUiState.Success], attaching the placeholder P&L and game - * activity stats (the Firestore/Room stats source lands in the final - * entrega). A failure in the identity stream surfaces as - * [ProfileUiState.Error]; [refresh] re-subscribes so the screen's retry - * action works. When the user signs out the stream emits null and the - * VM falls back to [ProfileUiState.Loading] while navigation leaves the - * screen. + * Combines [AuthRepository.currentUser] (the real identity) with the + * Room-backed [GameRepository.rounds] (the real P&L and per-game + * activity) into [ProfileUiState.Success]. A failure in the identity + * stream surfaces as [ProfileUiState.Error]; [refresh] re-subscribes so + * the screen's retry action works. When the user signs out the stream + * emits null and the VM falls back to [ProfileUiState.Loading] while + * navigation leaves the screen. */ @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -36,16 +40,20 @@ class ProfileViewModel @Inject constructor( private val authRepository: AuthRepository, + private val gameRepository: GameRepository, ) : ViewModel() { private val retryTrigger = MutableStateFlow(0) val uiState: StateFlow = retryTrigger .flatMapLatest { - authRepository.currentUser - .map { user -> - if (user != null) ProfileUiState.Success(user.toProfileData()) else ProfileUiState.Loading - }.catch { emit(ProfileUiState.Error(PROFILE_LOAD_ERROR)) } + combine(authRepository.currentUser, gameRepository.rounds) { user, rounds -> + if (user != null) { + ProfileUiState.Success(user.toProfileData(rounds)) + } else { + ProfileUiState.Loading + } + }.catch { emit(ProfileUiState.Error(PROFILE_LOAD_ERROR)) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), @@ -62,7 +70,7 @@ class ProfileViewModel retryTrigger.update { it + 1 } } - private fun AuthUser.toProfileData(): ProfileData { + private fun AuthUser.toProfileData(rounds: List): ProfileData { val name = displayName ?: FALLBACK_DISPLAY_NAME return ProfileData( identity = @@ -72,17 +80,83 @@ class ProfileViewModel initials = initialsOf(name), photoUrl = photoUrl, ), - pnl = placeholderPnl(), - gameActivity = placeholderGameActivity(), + pnl = rounds.toPnl(), + gameActivity = rounds.toGameActivity(), memberSinceLabel = MEMBER_SINCE_PLACEHOLDER, isVerified = false, appVersionLabel = APP_VERSION_LABEL, ) } + private fun List.toPnl(): ProfilePnl { + val bets = size + val wagered = sumOf { it.stake } + val won = sumOf { it.payout } + val net = won - wagered + val wins = count { it.outcome == RoundOutcome.Win } + val winRate = if (bets == 0) 0.0 else wins * PERCENT / bets + val wonFraction = if (wagered > 0.0) (won / wagered).toFloat().coerceIn(0f, 1f) else 0f + return ProfilePnl( + netLabel = signedMoney(net), + isNetNegative = net < 0, + betsLabel = "$bets bets", + winRateLabel = String.format(Locale.US, "%.1f%% win rate", winRate), + wonLabel = "Won $" + money(won), + wageredLabel = "Wagered $" + money(wagered), + wonFraction = wonFraction, + ) + } + + private fun List.toGameActivity(): List { + val stats = + groupBy { it.game } + .map { (game, rows) -> + GameStat( + game = game, + bets = rows.size, + wagered = rows.sumOf { it.stake }, + pnl = rows.sumOf { it.netProfit }, + ) + }.sortedByDescending { it.wagered } + if (stats.isEmpty()) return emptyList() + val maxWagered = stats.maxOf { it.wagered }.coerceAtLeast(1.0) + return stats.map { stat -> + GameActivityRow( + game = stat.game, + betsWageredLabel = "${stat.bets} bets ยท $" + money(stat.wagered) + " wagered", + pnlLabel = signedMoney(stat.pnl), + isPositive = stat.pnl >= 0, + fillFraction = (stat.wagered / maxWagered).toFloat().coerceIn(0f, 1f), + accent = stat.game.activityAccent(), + ) + } + } + + private fun GameKey.activityAccent(): ActivityAccent = + when (this) { + GameKey.Crash -> ActivityAccent.Violet + GameKey.Roulette -> ActivityAccent.Warn + GameKey.Blackjack -> ActivityAccent.Info + GameKey.Mines -> ActivityAccent.Ok + GameKey.Coinflip -> ActivityAccent.Danger + } + + private fun money(value: Double): String = String.format(Locale.US, "%,.2f", value) + + private fun signedMoney(value: Double): String = (if (value < 0) "-$" else "+$") + money(kotlin.math.abs(value)) + + /** Per-game aggregate behind one game-activity row. */ + private data class GameStat( + val game: GameKey, + val bets: Int, + val wagered: Double, + val pnl: Double, + ) + private companion object { const val STOP_TIMEOUT_MILLIS = 5_000L const val FALLBACK_DISPLAY_NAME = "Player" + const val PERCENT = 100.0 const val PROFILE_LOAD_ERROR = "Couldn't load your profile. Check your connection and try again." } diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt index 268860f..6f35077 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt @@ -3,6 +3,10 @@ package com.plainstudio.stackcasino.feature.profile import app.cash.turbine.test import com.plainstudio.stackcasino.domain.auth.AuthRepository import com.plainstudio.stackcasino.domain.auth.AuthUser +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.testing.MainDispatcherRule import io.mockk.Runs import io.mockk.coEvery @@ -15,6 +19,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test @@ -23,6 +28,7 @@ class ProfileViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val authRepository = mockk() + private val gameRepository = mockk(relaxed = true) private val sampleUser = AuthUser( @@ -32,12 +38,19 @@ class ProfileViewModelTest { photoUrl = null, ) + @Before + fun setUp() { + every { gameRepository.rounds } returns flowOf(emptyList()) + } + + private fun viewModel() = ProfileViewModel(authRepository, gameRepository) + @Test fun `maps the signed-in user into a Success state`() = runTest { every { authRepository.currentUser } returns flowOf(sampleUser) - ProfileViewModel(authRepository).uiState.test { + viewModel().uiState.test { val success = awaitSuccess() assertEquals("John Doe", success.data.identity.displayName) assertEquals("john.doe@gmail.com", success.data.identity.email) @@ -51,19 +64,58 @@ class ProfileViewModelTest { runTest { every { authRepository.currentUser } returns flowOf(sampleUser.copy(displayName = null)) - ProfileViewModel(authRepository).uiState.test { + viewModel().uiState.test { val success = awaitSuccess() assertEquals("Player", success.data.identity.displayName) cancelAndIgnoreRemainingEvents() } } + @Test + fun `derives P&L and game activity from the rounds`() = + runTest { + every { authRepository.currentUser } returns flowOf(sampleUser) + every { gameRepository.rounds } returns + flowOf( + listOf( + round(stake = 10.0, payout = 20.0, won = true), + round(stake = 50.0, payout = 0.0, won = false), + ), + ) + + viewModel().uiState.test { + val pnl = awaitSuccess().data.pnl + // wagered 60, won 20, net -40 + assertEquals("-$40.00", pnl.netLabel) + assertTrue(pnl.isNetNegative) + assertEquals("2 bets", pnl.betsLabel) + assertEquals("Won $20.00", pnl.wonLabel) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `groups game activity by game`() = + runTest { + every { authRepository.currentUser } returns flowOf(sampleUser) + every { gameRepository.rounds } returns + flowOf(listOf(round(stake = 10.0, payout = 20.0, won = true))) + + viewModel().uiState.test { + val activity = awaitSuccess().data.gameActivity + assertEquals(1, activity.size) + assertEquals(GameKey.Coinflip, activity[0].game) + assertEquals("+$10.00", activity[0].pnlLabel) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `surfaces Error when the identity stream fails`() = runTest { every { authRepository.currentUser } returns flow { throw IllegalStateException("boom") } - ProfileViewModel(authRepository).uiState.test { + viewModel().uiState.test { var state = awaitItem() while (state is ProfileUiState.Loading) state = awaitItem() assertTrue(state is ProfileUiState.Error) @@ -77,10 +129,31 @@ class ProfileViewModelTest { every { authRepository.currentUser } returns flowOf(sampleUser) coEvery { authRepository.signOut() } just Runs - ProfileViewModel(authRepository).signOut() + viewModel().signOut() coVerify(exactly = 1) { authRepository.signOut() } } + + private fun round( + stake: Double, + payout: Double, + won: Boolean, + ) = GameRound( + id = "round-id", + game = GameKey.Coinflip, + pick = "Heads", + stake = stake, + payout = payout, + outcome = if (won) RoundOutcome.Win else RoundOutcome.Loss, + multiplier = if (won) 2.0 else 0.0, + currency = "USDC", + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + resultRaw = "0", + timestamp = 0L, + ) } /**