From 5685966f43665fe5f8d26093b73f328fa8886bcf Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:59:13 -0300 Subject: [PATCH] feat(rounddetail): port the cu-11 round detail screen A static, full-screen view reached from the history list: the result banner, the three-up stats grid, the crash position bar with cashout and crash markers, the round timeline, a collapsible provably-fair block whose seeds copy to the clipboard, and the audit-log reference. Replaces the round detail placeholder in StackNavHost and drops the now-unused requireStringArg helper. The screen has no ViewModel: it renders RoundDetailData directly, seeded from roundDetailPreviewData() until a rounds repository can resolve a round by id. It lives in its own feature/rounddetail package so its internal layout tokens do not collide with the history screen's. Tests: RoundDetailFormatTest (the truncateMiddle seed shortener) and RoundDetailScreenTest (banner / stats / timeline render, the provably-fair expand, and the back callback). --- CHANGELOG.md | 5 + .../rounddetail/RoundDetailScreenTest.kt | 63 +++ .../feature/rounddetail/RoundDetailData.kt | 134 ++++++ .../rounddetail/RoundDetailProvablyFair.kt | 211 +++++++++ .../feature/rounddetail/RoundDetailScreen.kt | 414 ++++++++++++++++++ .../rounddetail/RoundDetailTimeline.kt | 97 ++++ .../stackcasino/navigation/StackNavHost.kt | 19 +- .../rounddetail/RoundDetailFormatTest.kt | 24 + 8 files changed, 955 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt create mode 100644 app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ab39865..3f73156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Round detail screen (cu-11): a static, full-screen view reached from + the history list with the result banner, the three-up stats grid, the + crash position bar, the round timeline, a collapsible provably-fair + block whose seeds copy to the clipboard, and the audit-log reference. + Replaces the round detail placeholder in the nav graph. - Profile screen (cu-15): `ProfileViewModel` maps the signed-in `AuthRepository.currentUser` into the identity block (name, email, Google provider, avatar monogram); the P&L summary and game-activity diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt new file mode 100644 index 0000000..8159c14 --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt @@ -0,0 +1,63 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +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.onNodeWithContentDescription +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.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoundDetailScreenTest { + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen(onBack: () -> Unit = {}) { + composeRule.setContent { + StackcasinoTheme { + RoundDetailScreen(data = roundDetailPreviewData(), onBack = onBack) + } + } + } + + @Test + fun renders_the_result_banner_stats_and_timeline() { + setScreen() + + composeRule.onNodeWithText("Round Detail").assertIsDisplayed() + composeRule.onNodeWithText("+$75.50").assertIsDisplayed() + composeRule.onNodeWithText("Crash Point").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Timeline").performScrollTo().assertIsDisplayed() + } + + @Test + fun provably_fair_is_collapsed_until_tapped() { + setScreen() + + composeRule.onAllNodesWithText("Server Seed").assertCountEquals(0) + + composeRule.onNodeWithText("Provably Fair").performScrollTo().performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("Server Seed").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Nonce").performScrollTo().assertIsDisplayed() + } + + @Test + fun back_button_invokes_the_callback() { + var backPressed = false + setScreen(onBack = { backPressed = true }) + + composeRule.onNodeWithContentDescription("Back").performClick() + + assertTrue(backPressed) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt new file mode 100644 index 0000000..1963ba3 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt @@ -0,0 +1,134 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome + +/** + * Everything the round detail screen renders. The screen is a static + * shell for now: [roundDetailPreviewData] mirrors the cu-11 mockup + * (mockup/js/screens/round-detail.js) and the live navigation entry + * uses it until a rounds repository can resolve a round by id. + * + * [crashBar] and [safeByLabel] are crash-specific and null for games + * that have no cashout-vs-crash visualization. + */ +data class RoundDetailData( + val game: GameKey, + val gameName: String, + val headerSubtitle: String, + val outcome: RoundOutcome, + val netProfitLabel: String, + val summaryLabel: String, + val stats: List, + val crashBar: CrashBar?, + val safeByLabel: String?, + val timeline: List, + val provablyFair: ProvablyFair, + val auditRef: String, +) + +/** One cell in the three-up stats grid. */ +data class RoundStat( + val label: String, + val value: String, + val tone: StatTone, +) + +enum class StatTone { Neutral, Accent, Danger } + +/** + * Crash position bar: [cashoutFraction] is where the player cashed out + * relative to the crash point (which sits at the full width). + */ +data class CrashBar( + val cashoutFraction: Float, + val cashoutLabel: String, + val crashLabel: String, +) + +/** One entry in the vertical round timeline. */ +data class TimelineEvent( + val label: String, + val timeLabel: String, + val tone: TimelineTone, +) + +enum class TimelineTone { Neutral, Ok, Danger } + +/** + * Provably-fair inputs for the round. Each [SeedValue] carries the full + * value (copied to the clipboard) plus its display label; the composable + * truncates the value for display via [truncateMiddle]. + */ +data class ProvablyFair( + val serverSeed: SeedValue, + val clientSeed: SeedValue, + val nonce: SeedValue, + val hmac: SeedValue, + val derivation: String, +) + +data class SeedValue( + val label: String, + val value: String, +) + +/** + * Shortens a long opaque value to `head...tail` with an ellipsis in the + * middle, leaving short values untouched. Used for the provably-fair + * seeds and hash so the full value stays one clipboard tap away while + * the row keeps a single line. + */ +internal fun truncateMiddle( + value: String, + keepHead: Int = SEED_KEEP_HEAD, + keepTail: Int = SEED_KEEP_TAIL, +): String = + if (value.length <= keepHead + keepTail + 1) { + value + } else { + value.take(keepHead) + "…" + value.takeLast(keepTail) + } + +private const val SEED_KEEP_HEAD = 8 +private const val SEED_KEEP_TAIL = 8 + +internal fun roundDetailPreviewData(): RoundDetailData = + RoundDetailData( + game = GameKey.Crash, + gameName = "Crash", + headerSubtitle = "Crash · #48291 · 19s · USDC", + outcome = RoundOutcome.Win, + netProfitLabel = "+$75.50", + summaryLabel = "Bet $50.00 · Payout $125.50", + stats = + listOf( + RoundStat(label = "Bet", value = "$50.00", tone = StatTone.Neutral), + RoundStat(label = "Cashed Out", value = "2.51x", tone = StatTone.Accent), + RoundStat(label = "Crash Point", value = "3.18x", tone = StatTone.Danger), + ), + crashBar = + CrashBar( + cashoutFraction = 0.789f, + cashoutLabel = "2.51x", + crashLabel = "3.18x", + ), + safeByLabel = "Safe by 2.1s", + timeline = + listOf( + TimelineEvent("Round started", "7:30:00 AM", TimelineTone.Neutral), + TimelineEvent("Bet placed · $50.00", "7:30:01 AM", TimelineTone.Neutral), + TimelineEvent("Cash out at 2.51x", "7:30:14 AM", TimelineTone.Ok), + TimelineEvent("Crashed at 3.18x", "7:30:19 AM", TimelineTone.Danger), + TimelineEvent("Payout credited · $125.50", "7:30:19 AM", TimelineTone.Ok), + ), + provablyFair = + ProvablyFair( + serverSeed = SeedValue("Server Seed", "a3f8c2e1b4d79f6a0c3e8b12"), + clientSeed = SeedValue("Client Seed", "7e2d4f8a1c3b5d9e0f2a6c84"), + nonce = SeedValue("Nonce", "48291"), + hmac = SeedValue("HMAC-SHA256 Hash", "e4a7f2c8d1b36e9a0f5c2d84a7b1e3f8c6d0a9b2e5f4"), + derivation = "Result derived from HMAC-SHA256(server_seed, client_seed:nonce)", + ), + auditRef = "AUD-98412", + ) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt new file mode 100644 index 0000000..a3cf272 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt @@ -0,0 +1,211 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.rotate +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium +import kotlinx.coroutines.delay + +/** + * Collapsible provably-fair block. Expands to reveal the server seed, + * client seed, nonce and HMAC hash; tapping a value copies the full + * (untruncated) string to the clipboard and flips that row to a + * transient "Copied" confirmation, reproducing the cu-11 seed-copied + * state. + */ +@Composable +internal fun RoundDetailProvablyFair(provablyFair: ProvablyFair) { + var expanded by rememberSaveable { mutableStateOf(false) } + var copiedLabel by remember { mutableStateOf(null) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(copiedLabel) { + if (copiedLabel != null) { + delay(COPIED_RESET_MILLIS) + copiedLabel = null + } + } + + Column(modifier = Modifier.fillMaxWidth().roundDetailCardSurface()) { + ProvablyFairHeader(expanded = expanded, onToggle = { expanded = !expanded }) + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.padding(horizontal = CardPadding, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HorizontalRule() + listOf( + provablyFair.serverSeed, + provablyFair.clientSeed, + provablyFair.nonce, + provablyFair.hmac, + ).forEach { seed -> + SeedRow( + seed = seed, + isCopied = copiedLabel == seed.label, + onCopy = { + clipboard.setText(AnnotatedString(seed.value)) + copiedLabel = seed.label + }, + ) + } + Text( + text = provablyFair.derivation, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + } +} + +@Composable +private fun ProvablyFairHeader( + expanded: Boolean, + onToggle: () -> Unit, +) { + val chevronRotation by animateFloatAsState( + targetValue = if (expanded) CHEVRON_EXPANDED_DEGREES else 0f, + label = "pf-chevron", + ) + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(LabelIconSize), + ) + Text( + text = "Provably Fair", + color = TextMedium, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Verify", + color = AccentViolet, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(LabelIconSize).rotate(chevronRotation), + ) + } + } +} + +@Composable +private fun SeedRow( + seed: SeedValue, + isCopied: Boolean, + onCopy: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = seed.label, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onCopy) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = truncateMiddle(seed.value), + color = TextMedium, + fontSize = SeedFontSize, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + if (isCopied) { + Text( + text = "Copied", + color = SemanticOk, + fontSize = FootnoteFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } else { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = "Copy ${seed.label}", + tint = TextLow, + modifier = Modifier.size(CopyIconSize), + ) + } + } + } +} + +@Composable +private fun HorizontalRule() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +private const val CHEVRON_EXPANDED_DEGREES = 180f +private const val COPIED_RESET_MILLIS = 2000L +private val LabelIconSize = 12.dp +private val CopyIconSize = 12.dp +private val SeedFontSize = 10.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt new file mode 100644 index 0000000..eade8c0 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt @@ -0,0 +1,414 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +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.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +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.StackCard +import com.plainstudio.stackcasino.ui.iconRes +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.SurfaceElevated +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 + +/** + * 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. + * + * 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. + */ +@Composable +fun RoundDetailScreen( + data: RoundDetailData, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize()) { + RoundDetailHeader( + subtitle = data.headerSubtitle, + onBack = onBack, + ) + HairlineDivider() + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(CardPadding), + verticalArrangement = Arrangement.spacedBy(CardPadding), + ) { + ResultBanner(data = data) + StatsGrid(stats = data.stats) + if (data.crashBar != null) { + CrashPositionCard(crashBar = data.crashBar, safeByLabel = data.safeByLabel) + } + RoundDetailTimeline(events = data.timeline) + RoundDetailProvablyFair(provablyFair = data.provablyFair) + AuditRow(auditRef = data.auditRef) + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } + } + } +} + +@Composable +private fun RoundDetailHeader( + subtitle: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + HeaderButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Back", + tint = TextHigh, + modifier = Modifier.size(HeaderIconSize), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(text = "Round Detail", color = TextHigh, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text( + text = subtitle, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + HeaderButton(onClick = { /* share intent ships with the rounds repository */ }) { + Icon( + imageVector = Icons.Outlined.IosShare, + contentDescription = "Share round", + tint = TextHigh, + modifier = Modifier.size(ShareIconSize), + ) + } + } +} + +@Composable +private fun HeaderButton( + onClick: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier = + Modifier + .size(HeaderButtonSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Composable +private fun ResultBanner(data: RoundDetailData) { + val accent = if (data.outcome == RoundOutcome.Win) SemanticOk else SemanticDanger + StackCard(modifier = Modifier.fillMaxWidth(), leftAccent = accent) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier.size(BannerIconBoxSize).border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(data.game.iconRes()), + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(BannerIconSize), + ) + } + Column { + Text(text = data.gameName, color = TextHigh, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = data.summaryLabel, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = data.netProfitLabel, + color = accent, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + Text( + text = "Net profit", + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + } +} + +@Composable +private fun StatsGrid(stats: List) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + stats.forEach { stat -> + Column(modifier = Modifier.weight(1f).roundDetailCardSurface().padding(12.dp)) { + Text( + text = stat.label, + color = TextMedium, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stat.value, + color = stat.tone.color(), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + } + } + } +} + +@Composable +private fun CrashPositionCard( + crashBar: CrashBar, + safeByLabel: String?, +) { + DetailCard(title = "Crash Position") { + CrashBarViz(crashBar = crashBar) + if (safeByLabel != null) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = safeByLabel, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun CrashBarViz(crashBar: CrashBar) { + Column { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(BarHeight) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline), + ) { + Box(modifier = Modifier.fillMaxHeight().fillMaxWidth().background(SurfaceElevated)) + Box( + modifier = + Modifier + .fillMaxHeight() + .fillMaxWidth(crashBar.cashoutFraction) + .background(AccentViolet.copy(alpha = CASHOUT_FILL_ALPHA)), + ) + } + Spacer(modifier = Modifier.height(4.dp)) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val fullWidth = maxWidth + CrashMarker( + label = crashBar.cashoutLabel, + caption = "You", + accent = AccentViolet, + modifier = Modifier.offset(x = fullWidth * crashBar.cashoutFraction), + ) + CrashMarker( + label = crashBar.crashLabel, + caption = "Crash", + accent = SemanticDanger, + alignEnd = true, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + } +} + +@Composable +private fun CrashMarker( + label: String, + caption: String, + accent: Color, + modifier: Modifier = Modifier, + alignEnd: Boolean = false, +) { + Column( + modifier = modifier, + horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start, + ) { + Box(modifier = Modifier.size(width = 1.dp, height = MarkerTickHeight).background(accent)) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + color = accent, + fontSize = MarkerFontSize, + fontWeight = FontWeight.SemiBold, + style = TabularNums, + ) + Text(text = caption, color = TextLow, fontSize = MarkerFontSize, letterSpacing = TrackedLetterSpacing) + } +} + +@Composable +private fun AuditRow(auditRef: String) { + Row( + modifier = Modifier.fillMaxWidth().roundDetailCardSurface().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(AuditIconSize), + ) + Text( + text = "Recorded in immutable Audit Log", + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Text( + text = auditRef, + color = TextLow, + fontSize = FootnoteFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } +} + +// --------------------------------------------------------------------------- +// Shared building blocks for the round detail section files +// --------------------------------------------------------------------------- + +internal fun Modifier.roundDetailCardSurface(): Modifier = + background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) + +@Composable +internal fun DetailCard( + title: String, + content: @Composable () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().roundDetailCardSurface().padding(CardPadding)) { + Text( + text = title, + color = TextMedium, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(12.dp)) + content() + } +} + +@Composable +private fun HairlineDivider() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +private fun StatTone.color(): Color = + when (this) { + StatTone.Neutral -> TextHigh + StatTone.Accent -> AccentViolet + StatTone.Danger -> SemanticDanger + } + +internal val CardPadding = 16.dp +internal val TrackedLetterSpacing = 1.2.sp +internal val LabelFontSize = 10.sp +internal val FootnoteFontSize = 9.sp + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val BottomScrollPadding = 24.dp +private val HeaderButtonSize = 36.dp +private val HeaderIconSize = 16.dp +private val ShareIconSize = 14.dp +private val BannerIconBoxSize = 40.dp +private val BannerIconSize = 18.dp +private val BarHeight = 8.dp +private val MarkerTickHeight = 8.dp +private val MarkerFontSize = 8.sp +private val AuditIconSize = 10.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 = {}) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt new file mode 100644 index 0000000..00acd18 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt @@ -0,0 +1,97 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Vertical round timeline: a connecting rail with a colored node per + * event (round start, bet, cashout, crash, payout) and the wall-clock + * time on the right. + */ +@Composable +internal fun RoundDetailTimeline(events: List) { + DetailCard(title = "Timeline") { + Column(modifier = Modifier.fillMaxWidth()) { + events.forEachIndexed { index, event -> + TimelineRow(event = event, isLast = index == events.lastIndex) + } + } + } +} + +@Composable +private fun TimelineRow( + event: TimelineEvent, + isLast: Boolean, +) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + Box(modifier = Modifier.width(RailWidth).fillMaxHeight()) { + // Connecting rail: a full-height hairline behind the node. + // The last row stops the rail at the node so it does not + // dangle past the final event. + if (!isLast) { + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .width(1.dp) + .fillMaxHeight() + .background(SurfaceOutline), + ) + } + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = NodeTopPadding) + .size(NodeSize) + .background(event.tone.color()), + ) + } + Row( + modifier = Modifier.weight(1f).padding(bottom = RowGap), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = event.label, color = TextMedium, fontSize = EventFontSize) + Text(text = event.timeLabel, color = TextLow, fontSize = EventFontSize, style = TabularNums) + } + } +} + +private fun TimelineTone.color(): Color = + when (this) { + TimelineTone.Neutral -> AccentViolet + TimelineTone.Ok -> SemanticOk + TimelineTone.Danger -> SemanticDanger + } + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val RailWidth = 16.dp +private val NodeSize = 7.dp +private val NodeTopPadding = 3.dp +private val RowGap = 16.dp +private val EventFontSize = 12.sp 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 bb424b5..e25da69 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -23,6 +23,8 @@ import com.plainstudio.stackcasino.feature.lobby.previewLobbyData import com.plainstudio.stackcasino.feature.news.NewsDetailScreen import com.plainstudio.stackcasino.feature.news.NewsScreen import com.plainstudio.stackcasino.feature.profile.ProfileScreen +import com.plainstudio.stackcasino.feature.rounddetail.RoundDetailScreen +import com.plainstudio.stackcasino.feature.rounddetail.roundDetailPreviewData import com.plainstudio.stackcasino.feature.wallet.WalletScreen import com.plainstudio.stackcasino.feature.wallet.WalletTab import com.plainstudio.stackcasino.feature.wallet.previewWalletData @@ -162,9 +164,11 @@ private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController composable( route = Route.RoundDetail.path, arguments = listOf(navArgument(Route.RoundDetail.ARG_ROUND_ID) { type = NavType.StringType }), - ) { entry -> - val id = entry.requireStringArg(Route.RoundDetail.ARG_ROUND_ID) - Placeholder("Round Detail · $id") + ) { + RoundDetailScreen( + data = roundDetailPreviewData(), + onBack = { navController.popBackStack() }, + ) } composable( route = Route.NewsDetail.path, @@ -197,15 +201,6 @@ private fun NavGraphBuilder.placeholderRoute( composable(route.path) { Placeholder(label) } } -/** - * Read a mandatory navigation argument. Missing arguments indicate a - * malformed navigate() call somewhere upstream; surface that loudly - * instead of silently falling back to an empty string. - */ -private fun androidx.navigation.NavBackStackEntry.requireStringArg(name: String): String = - arguments?.getString(name) - ?: error("Navigation argument '$name' is missing on ${destination.route}") - @Composable private fun Placeholder(label: String) { Box( diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt new file mode 100644 index 0000000..419aa01 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt @@ -0,0 +1,24 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RoundDetailFormatTest { + @Test + fun `short values are left untouched`() { + assertEquals("48291", truncateMiddle("48291")) + } + + @Test + fun `long values are shortened with a middle ellipsis`() { + assertEquals( + "e4a7f2c8…a9b2e5f4", + truncateMiddle("e4a7f2c8d1b36e9a0f5c2d84a7b1e3f8c6d0a9b2e5f4"), + ) + } + + @Test + fun `head and tail lengths are honored`() { + assertEquals("ab…yz", truncateMiddle("abcdefghijklmnopqrstuvwxyz", keepHead = 2, keepTail = 2)) + } +}