Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,18 @@ internal fun ProfileGameActivity(rows: List<GameActivityRow>) {
}
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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,56 @@ 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
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
class ProfileViewModel
@Inject
constructor(
private val authRepository: AuthRepository,
private val gameRepository: GameRepository,
) : ViewModel() {
private val retryTrigger = MutableStateFlow(0)

val uiState: StateFlow<ProfileUiState> =
retryTrigger
.flatMapLatest {
authRepository.currentUser
.map<AuthUser?, ProfileUiState> { 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),
Expand All @@ -62,7 +70,7 @@ class ProfileViewModel
retryTrigger.update { it + 1 }
}

private fun AuthUser.toProfileData(): ProfileData {
private fun AuthUser.toProfileData(rounds: List<GameRound>): ProfileData {
val name = displayName ?: FALLBACK_DISPLAY_NAME
return ProfileData(
identity =
Expand All @@ -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<GameRound>.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<GameRound>.toGameActivity(): List<GameActivityRow> {
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."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -23,6 +28,7 @@ class ProfileViewModelTest {
val mainDispatcherRule = MainDispatcherRule()

private val authRepository = mockk<AuthRepository>()
private val gameRepository = mockk<GameRepository>(relaxed = true)

private val sampleUser =
AuthUser(
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
)
}

/**
Expand Down
Loading