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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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`
into the list plus the aggregate summary (rounds, win rate, net), with
the search/filter still screen-local and a dedicated "no rounds yet"
empty state. `RoundDetailViewModel` resolves a round by its id
(`GameRepository.getRound`) and renders Loading / Success / NotFound;
the provably-fair block now shows the round's real serverSeed /
clientSeed / nonce / HMAC hash so the outcome is independently
verifiable. Covered by `HistoryViewModelTest`, `RoundDetailViewModelTest`
and the new `getRound` cases in `GameRepositoryImplTest`.

- Playable Coinflip (cu-09): the first game wired end-to-end through the
native provably-fair engine. `CoinflipViewModel` -> `GameRepository`
validates the stake against the Room wallet, draws the outcome on the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.plainstudio.stackcasino.feature.coinflip

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
Expand All @@ -17,10 +21,22 @@ class CoinflipScreenTest {
@get:Rule
val composeRule = createComposeRule()

/**
* Hosts the stateless screen with a local state holder so picking a
* side flows back through [CoinflipScreen]'s `onPick` callback and
* recomposes, mirroring how the ViewModel drives it in production.
*/
private fun setScreen(onBack: () -> Unit = {}) {
composeRule.setContent {
StackcasinoTheme {
CoinflipScreen(onBack = onBack)
var state by remember { mutableStateOf(CoinflipUiState()) }
CoinflipScreen(
state = state,
onBack = onBack,
onPick = { state = state.copy(pick = it) },
onPreset = { state = state.copy(betAmount = it) },
onFlip = {},
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class RoundDetailScreenTest {
private fun setScreen(onBack: () -> Unit = {}) {
composeRule.setContent {
StackcasinoTheme {
RoundDetailScreen(data = roundDetailPreviewData(), onBack = onBack)
RoundDetailScreen(state = RoundDetailUiState.Success(roundDetailPreviewData()), onBack = onBack)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class GameRepositoryImpl
override val rounds: Flow<List<GameRound>> =
dao.observeRounds().map { rows -> rows.map { it.toDomain() } }

override suspend fun getRound(id: String): GameRound? = dao.getRound(id)?.toDomain()

override suspend fun playCoinflip(
pick: CoinSide,
stake: Double,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ interface GameRepository {
/** Every settled round, newest first. */
val rounds: Flow<List<GameRound>>

/** Resolves a single persisted round by id, or null if it is unknown. */
suspend fun getRound(id: String): GameRound?

/**
* Plays one coinflip round: validates the stake against the wallet,
* draws the outcome on the native engine, settles the balance (x2 on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ fun HistoryScreen(
SummaryStrip(summary = data.summary)
RoundList(
rounds = visibleRounds,
allRoundsEmpty = data.rounds.isEmpty(),
onRoundTap = { round -> onOpenRound(round.id) },
)
Spacer(modifier = Modifier.height(BottomScrollPadding))
Expand Down Expand Up @@ -296,6 +297,7 @@ private fun SummaryCell(
@Composable
private fun RoundList(
rounds: List<HistoryRound>,
allRoundsEmpty: Boolean,
onRoundTap: (HistoryRound) -> Unit,
) {
Column(
Expand All @@ -306,7 +308,7 @@ private fun RoundList(
verticalArrangement = Arrangement.spacedBy(RoundGap),
) {
if (rounds.isEmpty()) {
EmptyRoundsState()
EmptyRoundsState(allRoundsEmpty = allRoundsEmpty)
} else {
rounds.forEach { round -> RoundCard(round = round, onClick = { onRoundTap(round) }) }
EndOfResultsSentinel()
Expand Down Expand Up @@ -458,7 +460,7 @@ private fun MetricCell(
}

@Composable
private fun EmptyRoundsState() {
private fun EmptyRoundsState(allRoundsEmpty: Boolean) {
Box(modifier = Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) {
EmptyState(
icon = {
Expand All @@ -469,8 +471,13 @@ private fun EmptyRoundsState() {
modifier = Modifier.size(EmptyIconSize),
)
},
title = "No rounds match",
message = "Try clearing the filters or your search query.",
title = if (allRoundsEmpty) "No rounds yet" else "No rounds match",
message =
if (allRoundsEmpty) {
"Play a game and your rounds will show up here."
} else {
"Try clearing the filters or your search query."
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.plainstudio.stackcasino.feature.history

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt

/**
* Drives the history screen. Maps the Room-backed [GameRepository.rounds]
* stream into the [HistoryData] the screen already renders: the summary
* aggregates over every settled round and each round becomes a row. The
* search and filter state stays screen-local.
*/
@HiltViewModel
class HistoryViewModel
@Inject
constructor(
gameRepository: GameRepository,
) : ViewModel() {
val historyData: StateFlow<HistoryData> =
gameRepository.rounds
.map { rounds -> rounds.toHistoryData() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), EMPTY_DATA)

private fun List<GameRound>.toHistoryData(): HistoryData =
HistoryData(
summary = toSummary(),
rounds = map { it.toRow() },
)

private fun List<GameRound>.toSummary(): HistorySummary {
val wins = count { it.outcome == RoundOutcome.Win }
val net = sumOf { it.netProfit }
return HistorySummary(
totalRounds = size,
winRatePercent = if (isEmpty()) 0 else (wins * PERCENT / size.toDouble()).roundToInt(),
netLabel = signedMoney(net),
)
}

private fun GameRound.toRow(): HistoryRound =
HistoryRound(
id = id,
game = game,
timestampLabel = TIMESTAMP_FORMAT.format(Date(timestamp)),
betLabel = "$" + formatMoney(stake),
payoutLabel = "$" + formatMoney(payout),
multiplierLabel = if (game == GameKey.Coinflip) pick else multiplierLabel(),
outcome = outcome,
)

private fun GameRound.multiplierLabel(): String {
val isWhole = multiplier % 1.0 == 0.0
val rounded = if (isWhole) multiplier.toInt().toString() else String.format(Locale.US, "%.2f", multiplier)
return rounded + "x"
}

private fun formatMoney(value: Double): String = String.format(Locale.US, "%,.2f", value)

private fun signedMoney(value: Double): String =
(if (value < 0) "-$" else "+$") + formatMoney(kotlin.math.abs(value))

private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
const val PERCENT = 100
val TIMESTAMP_FORMAT = SimpleDateFormat("M/d/yyyy · h:mm a", Locale.US)
val EMPTY_DATA =
HistoryData(
summary = HistorySummary(totalRounds = 0, winRatePercent = 0, netLabel = "+$0.00"),
rounds = emptyList(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.outlined.IosShare
import androidx.compose.material.icons.outlined.SearchOff
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
Expand All @@ -36,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.plainstudio.stackcasino.model.RoundOutcome
import com.plainstudio.stackcasino.ui.components.EmptyState
import com.plainstudio.stackcasino.ui.components.StackCard
import com.plainstudio.stackcasino.ui.iconRes
import com.plainstudio.stackcasino.ui.theme.AccentViolet
Expand All @@ -52,17 +54,30 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium

/**
* Round detail screen reproducing the cu-11 mockup
* (mockup/js/screens/round-detail.js). A static, full-screen view
* reached from the history list: result banner, stats grid, the crash
* position bar, the round timeline, the collapsible provably-fair block
* and the audit-log reference.
* (mockup/js/screens/round-detail.js). A full-screen view reached from
* the history list: result banner, stats grid, the crash position bar,
* the round timeline, the collapsible provably-fair block and the
* audit-log reference.
*
* No ViewModel: the screen renders [RoundDetailData] directly. The live
* navigation entry seeds it with [roundDetailPreviewData] until a rounds
* repository can resolve a round by id.
* Stateless: [RoundDetailViewModel] resolves the round id against the
* Room ledger and hands over [RoundDetailUiState] - [Success] with the
* mapped data, [Loading] while Room resolves, or [NotFound].
*/
@Composable
fun RoundDetailScreen(
state: RoundDetailUiState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
is RoundDetailUiState.Success -> RoundDetailContent(data = state.data, onBack = onBack, modifier = modifier)
RoundDetailUiState.Loading -> RoundDetailPlaceholder(onBack = onBack, modifier = modifier)
RoundDetailUiState.NotFound -> RoundDetailNotFound(onBack = onBack, modifier = modifier)
}
}

@Composable
private fun RoundDetailContent(
data: RoundDetailData,
onBack: () -> Unit,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -96,6 +111,49 @@ fun RoundDetailScreen(
}
}

@Composable
private fun RoundDetailPlaceholder(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) {
Column(modifier = Modifier.fillMaxSize()) {
RoundDetailHeader(subtitle = "Loading round...", onBack = onBack)
HairlineDivider()
}
}
}

@Composable
private fun RoundDetailNotFound(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) {
Column(modifier = Modifier.fillMaxSize()) {
RoundDetailHeader(subtitle = "Round not found", onBack = onBack)
HairlineDivider()
Box(
modifier = Modifier.fillMaxSize().padding(CardPadding),
contentAlignment = Alignment.Center,
) {
EmptyState(
icon = {
Icon(
imageVector = Icons.Outlined.SearchOff,
contentDescription = null,
tint = TextLow,
modifier = Modifier.size(NotFoundIconSize),
)
},
title = "Round not found",
message = "This round is no longer available.",
)
}
}
}
}

@Composable
private fun RoundDetailHeader(
subtitle: String,
Expand Down Expand Up @@ -403,12 +461,13 @@ private val BarHeight = 8.dp
private val MarkerTickHeight = 8.dp
private val MarkerFontSize = 8.sp
private val AuditIconSize = 10.dp
private val NotFoundIconSize = 24.dp
private const val CASHOUT_FILL_ALPHA = 0.40f

@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200)
@Composable
private fun RoundDetailScreenPreview() {
StackcasinoTheme {
RoundDetailScreen(data = roundDetailPreviewData(), onBack = {})
RoundDetailScreen(state = RoundDetailUiState.Success(roundDetailPreviewData()), onBack = {})
}
}
Loading
Loading