diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/history/HistoryScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/history/HistoryScreenTest.kt new file mode 100644 index 0000000..d611f56 --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/history/HistoryScreenTest.kt @@ -0,0 +1,85 @@ +package com.plainstudio.stackcasino.feature.history + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HistoryScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun renders_summary_and_every_round() { + composeRule.setContent { + StackcasinoTheme { + HistoryScreen(data = historyPreviewData(), onOpenRound = {}) + } + } + + composeRule.onNodeWithText("Game History").assertIsDisplayed() + composeRule.onNodeWithText("128").assertIsDisplayed() + composeRule.onNodeWithText("54%").assertIsDisplayed() + composeRule.onNodeWithText("Crash").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Coinflip").performScrollTo().assertIsDisplayed() + } + + @Test + fun selecting_wins_filter_hides_losing_rounds() { + composeRule.setContent { + StackcasinoTheme { + HistoryScreen(data = historyPreviewData(), onOpenRound = {}) + } + } + + composeRule.onNodeWithText("WINS").performScrollTo().performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("Crash").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Roulette").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Mines").performScrollTo().assertIsDisplayed() + composeRule.onAllNodesWithText("Blackjack").assertCountEquals(0) + composeRule.onAllNodesWithText("Coinflip").assertCountEquals(0) + } + + @Test + fun selecting_a_game_filter_narrows_to_that_game() { + composeRule.setContent { + StackcasinoTheme { + HistoryScreen(data = historyPreviewData(), onOpenRound = {}) + } + } + + composeRule.onNodeWithText("BLACKJACK").performScrollTo().performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("Blackjack").performScrollTo().assertIsDisplayed() + composeRule.onAllNodesWithText("Crash").assertCountEquals(0) + composeRule.onAllNodesWithText("Roulette").assertCountEquals(0) + } + + @Test + fun tapping_a_round_invokes_callback_with_round_id() { + var opened: String? = null + composeRule.setContent { + StackcasinoTheme { + HistoryScreen(data = historyPreviewData(), onOpenRound = { opened = it }) + } + } + + composeRule.onNodeWithText("Crash").performScrollTo().performClick() + composeRule.waitForIdle() + + assertEquals("round-001", opened) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryPreviewData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryPreviewData.kt new file mode 100644 index 0000000..05289f4 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryPreviewData.kt @@ -0,0 +1,71 @@ +package com.plainstudio.stackcasino.feature.history + +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome + +/** + * Static seed for the history screen previews + live nav-host entry. + * Numbers and labels mirror mockup/js/screens/history.js so the + * rendered screen matches the design source one-for-one. + */ +internal fun historyPreviewData(): HistoryData = + HistoryData( + summary = previewSummary(), + rounds = previewRounds(), + ) + +private fun previewSummary(): HistorySummary = + HistorySummary( + totalRounds = 128, + winRatePercent = 54, + netLabel = "+$412.30", + ) + +private fun previewRounds(): List = + listOf( + HistoryRound( + id = "round-001", + game = GameKey.Crash, + timestampLabel = "4/16/2026 · 7:30 AM", + betLabel = "$50.00", + payoutLabel = "$125.50", + multiplierLabel = "2.51x", + outcome = RoundOutcome.Win, + ), + HistoryRound( + id = "round-002", + game = GameKey.Roulette, + timestampLabel = "4/16/2026 · 6:15 AM", + betLabel = "$25.00", + payoutLabel = "$175.00", + multiplierLabel = "7x", + outcome = RoundOutcome.Win, + ), + HistoryRound( + id = "round-003", + game = GameKey.Blackjack, + timestampLabel = "4/15/2026 · 11:20 AM", + betLabel = "$100.00", + payoutLabel = "$0.00", + multiplierLabel = "0x", + outcome = RoundOutcome.Loss, + ), + HistoryRound( + id = "round-004", + game = GameKey.Mines, + timestampLabel = "4/15/2026 · 8:45 AM", + betLabel = "$20.00", + payoutLabel = "$48.00", + multiplierLabel = "2.40x", + outcome = RoundOutcome.Win, + ), + HistoryRound( + id = "round-005", + game = GameKey.Coinflip, + timestampLabel = "4/14/2026 · 1:30 PM", + betLabel = "$75.00", + payoutLabel = "$0.00", + multiplierLabel = "Heads", + outcome = RoundOutcome.Loss, + ), + ) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt new file mode 100644 index 0000000..0819f1d --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt @@ -0,0 +1,577 @@ +package com.plainstudio.stackcasino.feature.history + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.ui.components.EmptyState +import com.plainstudio.stackcasino.ui.components.FilterChip +import com.plainstudio.stackcasino.ui.components.FilterChipRow +import com.plainstudio.stackcasino.ui.components.StackCard +import com.plainstudio.stackcasino.ui.components.gridBackground +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * History screen reproducing the cu-10 mockup + * (mockup/js/screens/history.js). Owns the screen-local search and + * filter state; the summary strip aggregates over the full unfiltered + * round set (matching the mockup where the strip shows 128 rounds + * even with only a handful visible below). + */ +@Composable +fun HistoryScreen( + data: HistoryData, + onOpenRound: (roundId: String) -> Unit, + modifier: Modifier = Modifier, +) { + var query by rememberSaveable { mutableStateOf("") } + var gameFilter by rememberSaveable { mutableStateOf(GameFilter.All) } + var resultFilter by rememberSaveable { mutableStateOf(ResultFilter.All) } + + val visibleRounds by remember(data.rounds, query, gameFilter, resultFilter) { + derivedStateOf { data.rounds.filter { it.matches(query, gameFilter, resultFilter) } } + } + + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column( + modifier = + Modifier + .fillMaxSize() + .gridBackground() + .verticalScroll(rememberScrollState()), + ) { + HistoryHeader() + HorizontalDivider() + SearchInput(value = query, onValueChange = { query = it }) + FiltersBlock( + gameFilter = gameFilter, + onGameFilterChange = { gameFilter = it }, + resultFilter = resultFilter, + onResultFilterChange = { resultFilter = it }, + ) + Spacer(modifier = Modifier.height(SectionGap)) + SummaryStrip(summary = data.summary) + RoundList( + rounds = visibleRounds, + onRoundTap = { round -> onOpenRound(round.id) }, + ) + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } + } +} + +@Composable +private fun HistoryHeader() { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = HeaderTopPadding, + bottom = HeaderBottomPadding, + ), + ) { + Text( + text = "Game History", + color = TextHigh, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun SearchInput( + value: String, + onValueChange: (String) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SearchVerticalPadding) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(SearchIconColumnWidth).height(SearchInputHeight), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(SearchIconSize), + ) + } + Box( + modifier = Modifier.weight(1f).padding(end = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(color = TextHigh, fontSize = SearchFontSize), + cursorBrush = SolidColor(AccentViolet), + modifier = Modifier.fillMaxWidth(), + ) + if (value.isEmpty()) { + Text( + text = "Search rounds...", + color = TextLow, + fontSize = SearchFontSize, + ) + } + } + } +} + +@Composable +private fun FiltersBlock( + gameFilter: GameFilter, + onGameFilterChange: (GameFilter) -> Unit, + resultFilter: ResultFilter, + onResultFilterChange: (ResultFilter) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(FilterGroupGap), + ) { + FilterGroup( + label = "Game", + chips = GameFilter.entries.map { FilterChip(key = it, label = it.label) }, + selected = gameFilter, + onSelect = onGameFilterChange, + wrap = true, + ) + FilterGroup( + label = "Result", + chips = ResultFilter.entries.map { FilterChip(key = it, label = it.label) }, + selected = resultFilter, + onSelect = onResultFilterChange, + wrap = false, + ) + } +} + +@Composable +private fun FilterGroup( + label: String, + chips: List>, + selected: T, + onSelect: (T) -> Unit, + wrap: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label.uppercase(), + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + FilterChipRow( + chips = chips, + selected = selected, + onSelect = onSelect, + wrap = wrap, + ) + } +} + +@Composable +private fun SummaryStrip(summary: HistorySummary) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline), + ) { + SummaryCell( + label = "Rounds", + value = summary.totalRounds.toString(), + valueColor = TextHigh, + modifier = Modifier.weight(1f), + ) + VerticalDivider() + SummaryCell( + label = "Win Rate", + value = "${summary.winRatePercent}%", + valueColor = SemanticOk, + modifier = Modifier.weight(1f), + ) + VerticalDivider() + SummaryCell( + label = "Net", + value = summary.netLabel, + valueColor = AccentViolet, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun SummaryCell( + label: String, + value: String, + valueColor: Color, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(SummaryCellPadding)) { + Text( + text = label.uppercase(), + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + color = valueColor, + fontSize = SummaryValueFontSize, + fontWeight = FontWeight.Bold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun RoundList( + rounds: List, + onRoundTap: (HistoryRound) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionGap), + verticalArrangement = Arrangement.spacedBy(RoundGap), + ) { + if (rounds.isEmpty()) { + EmptyRoundsState() + } else { + rounds.forEach { round -> RoundCard(round = round, onClick = { onRoundTap(round) }) } + EndOfResultsSentinel() + } + } +} + +@Composable +private fun RoundCard( + round: HistoryRound, + onClick: () -> Unit, +) { + val accent = if (round.outcome == RoundOutcome.Win) SemanticOk else SemanticDanger + StackCard( + modifier = Modifier.fillMaxWidth(), + leftAccent = accent, + onClick = onClick, + contentPadding = PaddingValues(16.dp), + ) { + Column { + RoundHeader(round = round) + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(SurfaceOutline), + ) + Spacer(modifier = Modifier.height(12.dp)) + RoundMetricsRow(round = round) + } + } +} + +@Composable +private fun RoundHeader(round: HistoryRound) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .size(RoundIconBoxSize) + .border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + RoundIcon(game = round.game) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = round.game.label(), + color = TextHigh, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = round.timestampLabel, + color = TextLow, + fontSize = TimestampFontSize, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(ChevronSize).padding(top = 2.dp), + ) + } +} + +@Composable +private fun RoundIcon(game: GameKey) { + if (game == GameKey.Coinflip) { + // Coinflip uses an inline x2 glyph instead of a drawable so the + // history list matches the mockup's font-mono badge. + Text( + text = "x2", + color = AccentViolet, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } else { + Icon( + painter = painterResource(game.iconRes()), + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(RoundIconSize), + ) + } +} + +@Composable +private fun RoundMetricsRow(round: HistoryRound) { + val payoutColor = + when { + round.outcome == RoundOutcome.Loss -> TextLow + else -> SemanticOk + } + val multiplierColor = + if (round.outcome == RoundOutcome.Loss) TextLow else AccentViolet + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MetricCell(label = "Bet", value = round.betLabel, valueColor = TextHigh, modifier = Modifier.weight(1f)) + MetricCell( + label = "Payout", + value = round.payoutLabel, + valueColor = payoutColor, + modifier = Modifier.weight(1f), + ) + MetricCell( + label = round.thirdMetricLabel(), + value = round.multiplierLabel, + valueColor = multiplierColor, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun MetricCell( + label: String, + value: String, + valueColor: Color, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = label.uppercase(), + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + color = valueColor, + fontSize = MetricFontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun EmptyRoundsState() { + Box(modifier = Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) { + EmptyState( + icon = { + Icon( + imageVector = Icons.Outlined.History, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(EmptyIconSize), + ) + }, + title = "No rounds match", + message = "Try clearing the filters or your search query.", + ) + } +} + +@Composable +private fun EndOfResultsSentinel() { + Box(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), contentAlignment = Alignment.Center) { + Text( + text = "END OF RESULTS", + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun HorizontalDivider() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +@Composable +private fun VerticalDivider() { + Box(modifier = Modifier.width(1.dp).height(SummaryDividerHeight).background(SurfaceOutline)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +private fun HistoryRound.matches( + query: String, + gameFilter: GameFilter, + resultFilter: ResultFilter, +): Boolean { + if (gameFilter.match != null && game != gameFilter.match) return false + if (resultFilter.match != null && outcome != resultFilter.match) return false + if (query.isBlank()) return true + val trimmed = query.trim() + return game.label().contains(trimmed, ignoreCase = true) || + id.contains(trimmed, ignoreCase = true) +} + +private fun HistoryRound.thirdMetricLabel(): String = if (game == GameKey.Coinflip) "Prediction" else "Multiplier" + +private fun GameKey.label(): String = + when (this) { + GameKey.Roulette -> "Roulette" + GameKey.Blackjack -> "Blackjack" + GameKey.Crash -> "Crash" + GameKey.Mines -> "Mines" + GameKey.Coinflip -> "Coinflip" + } + +private fun GameKey.iconRes(): Int = + when (this) { + GameKey.Roulette -> R.drawable.ic_game_roulette + GameKey.Blackjack -> R.drawable.ic_game_blackjack + GameKey.Crash -> R.drawable.ic_game_crash + GameKey.Mines -> R.drawable.ic_game_mines + GameKey.Coinflip -> R.drawable.ic_game_coinflip + } + +// --------------------------------------------------------------------------- +// Tokens +// --------------------------------------------------------------------------- + +private val ScreenHorizontalPadding = 16.dp +private val SectionGap = 16.dp +private val HeaderTopPadding = 24.dp +private val HeaderBottomPadding = 16.dp +private val BottomScrollPadding = 96.dp + +private val SearchVerticalPadding = 16.dp +private val SearchIconColumnWidth = 44.dp +private val SearchInputHeight = 40.dp +private val SearchIconSize = 16.dp +private val SearchFontSize = 14.sp + +private val FilterGroupGap = 12.dp +private val MetaFontSize = 10.sp +private val SmallMetaFontSize = 9.sp +private val TrackedLetterSpacing = 1.2.sp + +private val SummaryCellPadding = 12.dp +private val SummaryValueFontSize = 18.sp +private val SummaryDividerHeight = 56.dp + +private val RoundGap = 8.dp +private val RoundIconBoxSize = 40.dp +private val RoundIconSize = 18.dp +private val ChevronSize = 14.dp +private val TimestampFontSize = 11.sp +private val MetricFontSize = 14.sp +private val EmptyIconSize = 24.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1400) +@Composable +private fun HistoryScreenPreview() { + StackcasinoTheme { + HistoryScreen(data = historyPreviewData(), onOpenRound = {}) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryUiState.kt new file mode 100644 index 0000000..28bfc33 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryUiState.kt @@ -0,0 +1,68 @@ +package com.plainstudio.stackcasino.feature.history + +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome + +/** + * Aggregate snapshot the history screen renders. A static + * [historyPreviewData] seeds both the @Preview composables and the + * live nav-host entry until the Firestore-backed VM ships. + * + * [summary] is the aggregate over the full unfiltered round set + * (mockup spec shows 128 rounds even with only five visible); the + * screen-local filter + search apply to [rounds] only. + */ +data class HistoryData( + val summary: HistorySummary, + val rounds: List, +) + +data class HistorySummary( + val totalRounds: Int, + val winRatePercent: Int, + val netLabel: String, +) + +/** + * Single round row. [multiplierLabel] becomes a Prediction label for + * coinflip ([multiplierLabel] always carries the right-most cell's + * value, the heading is decided by [game]). + */ +data class HistoryRound( + val id: String, + val game: GameKey, + val timestampLabel: String, + val betLabel: String, + val payoutLabel: String, + val multiplierLabel: String, + val outcome: RoundOutcome, +) + +/** + * Single-select filter applied to the game column. + * [All] keeps every game; the rest narrow the list to one game. + */ +enum class GameFilter( + val label: String, + val match: GameKey?, +) { + All("All", null), + Roulette("Roulette", GameKey.Roulette), + Blackjack("Blackjack", GameKey.Blackjack), + Crash("Crash", GameKey.Crash), + Mines("Mines", GameKey.Mines), + Coinflip("Coinflip", GameKey.Coinflip), +} + +/** + * Single-select filter applied to the outcome column. + * Null [match] means "do not filter on outcome". + */ +enum class ResultFilter( + val label: String, + val match: RoundOutcome?, +) { + All("All", null), + Wins("Wins", RoundOutcome.Win), + Losses("Losses", RoundOutcome.Loss), +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyPreviewData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyPreviewData.kt index 46f7165..1c10ca0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyPreviewData.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyPreviewData.kt @@ -1,5 +1,8 @@ package com.plainstudio.stackcasino.feature.lobby +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome + /** * Static seed used by both the lobby @Preview composables and the live * navigation entry until the Firestore-backed ViewModel ships. diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt index 983b20a..ac4f647 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.navigation.Route import com.plainstudio.stackcasino.ui.components.BalancePill import com.plainstudio.stackcasino.ui.components.CurrencyDropdown diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyUiState.kt index 20b04ef..778ec39 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyUiState.kt @@ -1,5 +1,8 @@ package com.plainstudio.stackcasino.feature.lobby +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome + /** * UI state for the lobby screen. * @@ -83,14 +86,6 @@ data class GameCardData( val isLastPlayed: Boolean = false, ) -enum class GameKey { - Roulette, - Blackjack, - Crash, - Mines, - Coinflip, -} - /** * Recent activity row. * @@ -106,5 +101,3 @@ data class RecentRound( val amountLabel: String, val outcome: RoundOutcome, ) - -enum class RoundOutcome { Win, Loss } diff --git a/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt b/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt new file mode 100644 index 0000000..619768e --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt @@ -0,0 +1,23 @@ +package com.plainstudio.stackcasino.model + +/** + * Catalogue of playable games. Shared between any feature that needs + * to reference a game without coupling to the others' state types + * (lobby's recent activity, history's round filter, future game + * launcher routes, etc). + */ +enum class GameKey { + Roulette, + Blackjack, + Crash, + Mines, + Coinflip, +} + +/** + * Outcome of a single round. Push (tie) is not a real outcome in the + * supported games, so the model stays binary; if a game adds Push + * later the enum extends here and both lobby and history surface it + * uniformly. + */ +enum class RoundOutcome { Win, Loss } diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt index 1637625..35e989a 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -14,6 +14,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.plainstudio.stackcasino.feature.auth.LoginScreen +import com.plainstudio.stackcasino.feature.history.HistoryScreen +import com.plainstudio.stackcasino.feature.history.historyPreviewData import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData @@ -72,6 +74,16 @@ fun StackNavHost( }, ) } + composable(Route.History.path) { + HistoryScreen( + data = historyPreviewData(), + onOpenRound = { roundId -> + navController.navigate(Route.RoundDetail.build(roundId)) { + launchSingleTop = true + } + }, + ) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -106,7 +118,6 @@ fun StackNavHost( private val PLACEHOLDER_ROUTES: List> = listOf( Route.HouseWallet to "House Wallet", - Route.History to "History", Route.News to "News", Route.Profile to "Profile", Route.Kyc to "KYC", diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt index b3b9ef9..6bd171a 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -36,10 +38,17 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium * gap: 8dp between chips * * Generic over the chip key type so callers stay type-safe (an enum, - * sealed type, or domain id works equally well). [scrollable] flips - * the layout from a wrapped Row to a horizontal-scroll lane (news - * source filters use this; wallet tabs do not). + * sealed type, or domain id works equally well). + * + * Layout flags (mutually exclusive; precedence order is the same as the + * parameter list): + * * [scrollable] -> horizontal-scroll lane (news source filters). + * * [wrap] -> FlowRow that wraps onto multiple lines when the + * chip set overflows (history game filter). + * * default -> single Row (wallet transaction tabs, history + * result filter, and any tight chip set). */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun FilterChipRow( chips: List>, @@ -47,24 +56,41 @@ fun FilterChipRow( onSelect: (T) -> Unit, modifier: Modifier = Modifier, scrollable: Boolean = false, + wrap: Boolean = false, ) { - val container = - if (scrollable) { - modifier.horizontalScroll(rememberScrollState()) - } else { - modifier + when { + scrollable -> { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalAlignment = Alignment.CenterVertically, + ) { + chips.forEach { chip -> + Chip(label = chip.label, isActive = chip.key == selected, onClick = { onSelect(chip.key) }) + } + } } - Row( - modifier = container, - horizontalArrangement = Arrangement.spacedBy(ChipGap), - verticalAlignment = Alignment.CenterVertically, - ) { - chips.forEach { chip -> - Chip( - label = chip.label, - isActive = chip.key == selected, - onClick = { onSelect(chip.key) }, - ) + wrap -> { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalArrangement = Arrangement.spacedBy(ChipGap), + ) { + chips.forEach { chip -> + Chip(label = chip.label, isActive = chip.key == selected, onClick = { onSelect(chip.key) }) + } + } + } + else -> { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalAlignment = Alignment.CenterVertically, + ) { + chips.forEach { chip -> + Chip(label = chip.label, isActive = chip.key == selected, onClick = { onSelect(chip.key) }) + } + } } } }