diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt index 3a615ef..64587e0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt @@ -69,21 +69,16 @@ class GameRepositoryImpl val wallet = walletRepository.wallet.first() stakeError(stake, wallet.availableBalance)?.let { return PlayResult.Failure(it) } - val serverSeed = seedGenerator.newSeed() - val clientSeed = seedGenerator.newSeed() - val roundNonce = nonce.incrementAndGet() + val draw = newDraw() val outcome = - runCatching { engine.evaluateCoinflip(serverSeed, clientSeed, roundNonce) } - .getOrElse { return PlayResult.Failure("The game engine is unavailable.") } + evaluate { engine.evaluateCoinflip(draw.serverSeed, draw.clientSeed, draw.nonce) } + ?: return PlayResult.Failure(ENGINE_UNAVAILABLE) val landed = CoinSide.fromEngineResult(outcome.result) val won = landed == pick val payout = if (won) stake * COINFLIP_MULTIPLIER else 0.0 - when (walletRepository.applyRoundResult(stake, payout)) { - BetSettlement.InsufficientFunds -> return PlayResult.Failure(INSUFFICIENT_FUNDS) - BetSettlement.Success -> Unit - } + if (!charge(stake, payout)) return PlayResult.Failure(INSUFFICIENT_FUNDS) val round = GameRound( @@ -95,9 +90,9 @@ class GameRepositoryImpl outcome = if (won) RoundOutcome.Win else RoundOutcome.Loss, multiplier = if (won) COINFLIP_MULTIPLIER else 0.0, currency = wallet.currencyCode, - serverSeed = serverSeed, - clientSeed = clientSeed, - nonce = roundNonce, + serverSeed = draw.serverSeed, + clientSeed = draw.clientSeed, + nonce = draw.nonce, hash = outcome.hash, resultRaw = outcome.result.trim(), timestamp = timeProvider.now().time, @@ -113,17 +108,12 @@ class GameRepositoryImpl val wallet = walletRepository.wallet.first() minesError(numMines, stake, wallet.availableBalance)?.let { return MinesStart.Failure(it) } - val serverSeed = seedGenerator.newSeed() - val clientSeed = seedGenerator.newSeed() - val roundNonce = nonce.incrementAndGet() + val draw = newDraw() val outcome = - runCatching { engine.evaluateMines(serverSeed, clientSeed, roundNonce, numMines) } - .getOrElse { return MinesStart.Failure("The game engine is unavailable.") } + evaluate { engine.evaluateMines(draw.serverSeed, draw.clientSeed, draw.nonce, numMines) } + ?: return MinesStart.Failure(ENGINE_UNAVAILABLE) - when (walletRepository.applyRoundResult(stake, 0.0)) { - BetSettlement.InsufficientFunds -> return MinesStart.Failure(INSUFFICIENT_FUNDS) - BetSettlement.Success -> Unit - } + if (!charge(stake, 0.0)) return MinesStart.Failure(INSUFFICIENT_FUNDS) return MinesStart.Success( MinesRound( id = idGenerator.newId(), @@ -131,9 +121,9 @@ class GameRepositoryImpl minePositions = parsePositions(outcome.result), stake = stake, currency = wallet.currencyCode, - serverSeed = serverSeed, - clientSeed = clientSeed, - nonce = roundNonce, + serverSeed = draw.serverSeed, + clientSeed = draw.clientSeed, + nonce = draw.nonce, hash = outcome.hash, resultRaw = outcome.result.trim(), ), @@ -147,13 +137,11 @@ class GameRepositoryImpl ) { val multiplier = if (cashedOut) MinesMath.multiplier(round.numMines, safeRevealed) else 0.0 val payout = round.stake * multiplier - if (cashedOut && payout > 0.0) { - walletRepository.applyRoundResult(0.0, payout) - } + creditPayout(payout) dao.insertRound( - GameRoundEntity( + gameRoundEntity( id = round.id, - game = GameKey.Mines.name, + game = GameKey.Mines, pick = "${round.numMines} mines", stake = round.stake, payout = payout, @@ -165,7 +153,6 @@ class GameRepositoryImpl nonce = round.nonce, hash = round.hash, resultRaw = round.resultRaw, - timestamp = timeProvider.now().time, ), ) } @@ -174,19 +161,14 @@ class GameRepositoryImpl val wallet = walletRepository.wallet.first() stakeError(stake, wallet.availableBalance)?.let { return CrashStart.Failure(it) } - val serverSeed = seedGenerator.newSeed() - val clientSeed = seedGenerator.newSeed() - val roundNonce = nonce.incrementAndGet() + val draw = newDraw() val drawn = - runCatching { - val outcome = engine.evaluateCrashPoint(serverSeed, clientSeed, roundNonce) + evaluate { + val outcome = engine.evaluateCrashPoint(draw.serverSeed, draw.clientSeed, draw.nonce) outcome to outcome.result.trim().toDouble() - }.getOrElse { return CrashStart.Failure("The game engine is unavailable.") } + } ?: return CrashStart.Failure(ENGINE_UNAVAILABLE) - when (walletRepository.applyRoundResult(stake, 0.0)) { - BetSettlement.InsufficientFunds -> return CrashStart.Failure(INSUFFICIENT_FUNDS) - BetSettlement.Success -> Unit - } + if (!charge(stake, 0.0)) return CrashStart.Failure(INSUFFICIENT_FUNDS) val (outcome, crashPoint) = drawn return CrashStart.Success( CrashRound( @@ -194,9 +176,9 @@ class GameRepositoryImpl stake = stake, currency = wallet.currencyCode, crashPoint = crashPoint, - serverSeed = serverSeed, - clientSeed = clientSeed, - nonce = roundNonce, + serverSeed = draw.serverSeed, + clientSeed = draw.clientSeed, + nonce = draw.nonce, hash = outcome.hash, resultRaw = outcome.result.trim(), ), @@ -209,13 +191,11 @@ class GameRepositoryImpl cashedOut: Boolean, ) { val payout = if (cashedOut) round.stake * cashoutMultiplier else 0.0 - if (cashedOut && payout > 0.0) { - walletRepository.applyRoundResult(0.0, payout) - } + creditPayout(payout) dao.insertRound( - GameRoundEntity( + gameRoundEntity( id = round.id, - game = GameKey.Crash.name, + game = GameKey.Crash, pick = "", stake = round.stake, payout = payout, @@ -227,7 +207,6 @@ class GameRepositoryImpl nonce = round.nonce, hash = round.hash, resultRaw = round.resultRaw, - timestamp = timeProvider.now().time, ), ) } @@ -237,19 +216,14 @@ class GameRepositoryImpl val wallet = walletRepository.wallet.first() stakeError(totalStake, wallet.availableBalance)?.let { return RouletteStart.Failure(it) } - val serverSeed = seedGenerator.newSeed() - val clientSeed = seedGenerator.newSeed() - val roundNonce = nonce.incrementAndGet() + val draw = newDraw() val drawn = - runCatching { - val outcome = engine.evaluateRoulette(serverSeed, clientSeed, roundNonce) + evaluate { + val outcome = engine.evaluateRoulette(draw.serverSeed, draw.clientSeed, draw.nonce) outcome to outcome.result.trim().toInt() - }.getOrElse { return RouletteStart.Failure("The game engine is unavailable.") } + } ?: return RouletteStart.Failure(ENGINE_UNAVAILABLE) - when (walletRepository.applyRoundResult(totalStake, 0.0)) { - BetSettlement.InsufficientFunds -> return RouletteStart.Failure(INSUFFICIENT_FUNDS) - BetSettlement.Success -> Unit - } + if (!charge(totalStake, 0.0)) return RouletteStart.Failure(INSUFFICIENT_FUNDS) val (outcome, pocket) = drawn return RouletteStart.Success( RouletteSpin( @@ -258,9 +232,9 @@ class GameRepositoryImpl totalStake = totalStake, totalReturn = RouletteMath.totalReturn(bets, pocket), currency = wallet.currencyCode, - serverSeed = serverSeed, - clientSeed = clientSeed, - nonce = roundNonce, + serverSeed = draw.serverSeed, + clientSeed = draw.clientSeed, + nonce = draw.nonce, hash = outcome.hash, resultRaw = outcome.result.trim(), ), @@ -268,25 +242,22 @@ class GameRepositoryImpl } override suspend fun settleRouletteSpin(spin: RouletteSpin) { - if (spin.totalReturn > 0.0) { - walletRepository.applyRoundResult(0.0, spin.totalReturn) - } + creditPayout(spin.totalReturn) dao.insertRound( - GameRoundEntity( + gameRoundEntity( id = spin.id, - game = GameKey.Roulette.name, + game = GameKey.Roulette, pick = "", stake = spin.totalStake, payout = spin.totalReturn, won = spin.totalReturn > spin.totalStake, - multiplier = if (spin.totalStake > 0.0) spin.totalReturn / spin.totalStake else 0.0, + multiplier = safeMultiplier(spin.totalReturn, spin.totalStake), currency = spin.currency, serverSeed = spin.serverSeed, clientSeed = spin.clientSeed, nonce = spin.nonce, hash = spin.hash, resultRaw = spin.resultRaw, - timestamp = timeProvider.now().time, ), ) } @@ -295,15 +266,13 @@ class GameRepositoryImpl val wallet = walletRepository.wallet.first() stakeError(stake, wallet.availableBalance)?.let { return BlackjackStart.Failure(it) } - val serverSeed = seedGenerator.newSeed() - val clientSeed = seedGenerator.newSeed() - val roundNonce = nonce.incrementAndGet() + val draw = newDraw() val drawn = - runCatching { - val outcome = engine.evaluateBlackjackDeck(serverSeed, clientSeed, roundNonce) + evaluate { + val outcome = engine.evaluateBlackjackDeck(draw.serverSeed, draw.clientSeed, draw.nonce) // Keep the deck order (a Set would reshuffle it). - outcome to outcome.result.split(",").map { it.trim().toInt() } - }.getOrElse { return BlackjackStart.Failure("The game engine is unavailable.") } + outcome to outcome.result.split(ENGINE_CSV_DELIMITER).map { it.trim().toInt() } + } ?: return BlackjackStart.Failure(ENGINE_UNAVAILABLE) val (outcome, deck) = drawn return BlackjackStart.Success( @@ -312,9 +281,9 @@ class GameRepositoryImpl deck = deck, stake = stake, currency = wallet.currencyCode, - serverSeed = serverSeed, - clientSeed = clientSeed, - nonce = roundNonce, + serverSeed = draw.serverSeed, + clientSeed = draw.clientSeed, + nonce = draw.nonce, hash = outcome.hash, ), ) @@ -330,27 +299,44 @@ class GameRepositoryImpl ) { walletRepository.applyRoundResult(effectiveStake, payout) dao.insertRound( - GameRoundEntity( + gameRoundEntity( id = round.id, - game = GameKey.Blackjack.name, + game = GameKey.Blackjack, pick = outcome, stake = effectiveStake, payout = payout, won = payout > effectiveStake, - multiplier = if (effectiveStake > 0.0) payout / effectiveStake else 0.0, + multiplier = safeMultiplier(payout, effectiveStake), currency = round.currency, serverSeed = round.serverSeed, clientSeed = round.clientSeed, nonce = round.nonce, hash = round.hash, resultRaw = "$playerTotal/$dealerTotal", - timestamp = timeProvider.now().time, ), ) } + /** Fresh provably-fair draw inputs: two seeds plus the next positive nonce. */ + private fun newDraw(): Draw = Draw(seedGenerator.newSeed(), seedGenerator.newSeed(), nonce.incrementAndGet()) + + /** Settles stake/payout, returning false only when the wallet lacks funds. */ + private suspend fun charge( + stake: Double, + payout: Double, + ): Boolean = + when (walletRepository.applyRoundResult(stake, payout)) { + BetSettlement.Success -> true + BetSettlement.InsufficientFunds -> false + } + + /** Credits a winning payout (stake already charged); no-op when nothing is owed. */ + private suspend fun creditPayout(payout: Double) { + if (payout > 0.0) walletRepository.applyRoundResult(0.0, payout) + } + private fun parsePositions(csv: String): Set = - csv.split(",").mapNotNull { it.trim().toIntOrNull() }.toSet() + csv.split(ENGINE_CSV_DELIMITER).mapNotNull { it.trim().toIntOrNull() }.toSet() private fun minesError( numMines: Int, @@ -358,7 +344,7 @@ class GameRepositoryImpl available: Double, ): String? = if (numMines !in MinesMath.MIN_MINES..MinesMath.MAX_MINES) { - "Pick between 1 and 24 mines." + "Pick between ${MinesMath.MIN_MINES} and ${MinesMath.MAX_MINES} mines." } else { stakeError(stake, available) } @@ -374,13 +360,52 @@ class GameRepositoryImpl } private fun GameRound.toEntity() = + gameRoundEntity( + id = id, + game = game, + pick = pick, + stake = stake, + payout = payout, + won = outcome == RoundOutcome.Win, + multiplier = multiplier, + currency = currency, + serverSeed = serverSeed, + clientSeed = clientSeed, + nonce = nonce, + hash = hash, + resultRaw = resultRaw, + timestamp = timestamp, + ) + + /** + * Single builder for the Room [GameRoundEntity] rows, so the five + * settle paths share one field list. [timestamp] defaults to now; + * callers that already stamped the round (Coinflip) pass their own. + */ + @Suppress("LongParameterList") + private fun gameRoundEntity( + id: String, + game: GameKey, + pick: String, + stake: Double, + payout: Double, + won: Boolean, + multiplier: Double, + currency: String, + serverSeed: String, + clientSeed: String, + nonce: Long, + hash: String, + resultRaw: String, + timestamp: Long = timeProvider.now().time, + ): GameRoundEntity = GameRoundEntity( id = id, game = game.name, pick = pick, stake = stake, payout = payout, - won = outcome == RoundOutcome.Win, + won = won, multiplier = multiplier, currency = currency, serverSeed = serverSeed, @@ -409,8 +434,24 @@ class GameRepositoryImpl timestamp = timestamp, ) + private data class Draw( + val serverSeed: String, + val clientSeed: String, + val nonce: Long, + ) + private companion object { const val COINFLIP_MULTIPLIER = 2.0 const val INSUFFICIENT_FUNDS = "Not enough balance for this bet." + const val ENGINE_UNAVAILABLE = "The game engine is unavailable." + const val ENGINE_CSV_DELIMITER = "," } } + +private fun safeMultiplier( + payout: Double, + stake: Double, +): Double = if (stake > 0.0) payout / stake else 0.0 + +/** Runs an engine evaluation, returning null when the native engine throws. */ +private fun evaluate(block: () -> T): T? = runCatching(block).getOrNull() diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt index ecdf6f9..3be0322 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.PulsingDot import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticOk @@ -177,7 +178,7 @@ private fun AssistantHeader( onClick = onClear, ) } - Divider() + HairlineDivider() } @Composable @@ -236,7 +237,7 @@ private fun AssistantInputBar( .navigationBarsPadding() .padding(InputBarOuterPadding), ) { - Divider() + HairlineDivider() Spacer(modifier = Modifier.height(InputBarTopGap)) Row( modifier = Modifier.fillMaxWidth(), @@ -345,17 +346,6 @@ private fun InputBarFooter(charCount: Int) { } } -@Composable -internal fun Divider() { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(SurfaceOutline), - ) -} - // --------------------------------------------------------------------------- // Tokens shared across the assistant files. // --------------------------------------------------------------------------- diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt index 1e6bc23..6294f2c 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt @@ -7,14 +7,13 @@ import com.plainstudio.stackcasino.domain.assistant.ChatTurn import com.plainstudio.stackcasino.domain.assistant.Role import com.plainstudio.stackcasino.util.IdGenerator import com.plainstudio.stackcasino.util.TimeProvider +import com.plainstudio.stackcasino.util.TimestampFormat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Locale import javax.inject.Inject /** @@ -118,7 +117,7 @@ class AssistantViewModel copy(messages = messages + message) } - private fun nowLabel(): String = TIMESTAMP_FORMAT.format(timeProvider.now()) + private fun nowLabel(): String = TimestampFormat.timeOfDay(timeProvider.now().time) // Per-message handle so the LazyColumn key never collides on // repeated identical questions sent inside the same minute. @@ -126,6 +125,5 @@ class AssistantViewModel companion object { const val MAX_USER_MESSAGE_LENGTH = 300 - private val TIMESTAMP_FORMAT = SimpleDateFormat("h:mm a", Locale.US) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackRulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackRulesSheet.kt index 4375389..bcaccc0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackRulesSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackRulesSheet.kt @@ -1,105 +1,20 @@ package com.plainstudio.stackcasino.feature.blackjack -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.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 +import com.plainstudio.stackcasino.ui.components.RulesSheet /** - * Blackjack rules sheet reached from the header, styled after the - * mockup's legal sheets: a tracked label, a bold title and a meta line - * over the numbered rule sections. + * Blackjack rules sheet reached from the header. Thin wrapper over the + * shared [RulesSheet]; only the title, meta line and rule data differ. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BlackjackRulesSheet(onDismiss: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = SurfaceRaised, - contentColor = TextHigh, - ) { - Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), - ) { - Text( - text = "How to Play", - color = AccentViolet, - fontSize = LabelFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "Blackjack Rules", color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Classic · Dealer stands on 17 · RTP 99.5%", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TabularNums, - ) - } - Divider() - Column( - modifier = Modifier.fillMaxWidth().padding(SheetPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - RULES.forEach { (label, body) -> RuleSection(label = label, body = body) } - Spacer(modifier = Modifier.height(4.dp)) - Divider() - Text( - text = "End of rules · Must be 18+ to play", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), - ) - } - } - } -} - -@Composable -private fun RuleSection( - label: String, - body: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) - } -} - -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + RulesSheet( + title = "Blackjack Rules", + meta = "Classic · Dealer stands on 17 · RTP 99.5%", + rules = RULES, + onDismiss = onDismiss, + ) } private val RULES = @@ -122,9 +37,3 @@ private val RULES = "Card order is derived from HMAC-SHA256(server_seed, client_seed:nonce). The shuffle is revealed after " + "each round.", ) - -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") -private val SheetPadding = 20.dp -private val LabelFontSize = 10.sp -private val MetaFontSize = 9.sp -private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt index a230179..6cfc15b 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt @@ -1,7 +1,5 @@ package com.plainstudio.stackcasino.feature.blackjack -import android.content.Context -import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -9,22 +7,13 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -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.MenuBook -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 @@ -36,9 +25,6 @@ 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.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -46,7 +32,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.domain.game.BlackjackMath +import com.plainstudio.stackcasino.ui.components.GameActionButton +import com.plainstudio.stackcasino.ui.components.GameHeader +import com.plainstudio.stackcasino.ui.components.OutcomeTone +import com.plainstudio.stackcasino.ui.components.StatCell import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.CenteredGlyph +import com.plainstudio.stackcasino.ui.theme.DISABLED_ALPHA +import com.plainstudio.stackcasino.ui.theme.MetaFontSize +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticInfo import com.plainstudio.stackcasino.ui.theme.SemanticOk @@ -55,9 +49,12 @@ 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.TabularNums import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing /** * Blackjack game screen (mockup/js/screens/blackjack.js). @@ -84,7 +81,13 @@ fun BlackjackScreen( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { - BlackjackHeader(onBack = onBack, onOpenRules = { showRules = true }) + GameHeader( + title = "Blackjack", + subtitle = "Provably Fair · Classic · RTP 99.5%", + shareMessage = SHARE_MESSAGE, + onBack = onBack, + onOpenRules = { showRules = true }, + ) RecentStrip(results = state.recentResults) StatsStrip(state = state) Table(state = state, modifier = Modifier.weight(1f)) @@ -106,76 +109,6 @@ fun BlackjackScreen( } } -@Composable -private fun BlackjackHeader( - onBack: () -> Unit, - onOpenRules: () -> Unit, -) { - val context = LocalContext.current - Row( - modifier = - Modifier.fillMaxWidth().padding( - horizontal = ScreenHorizontalPadding, - vertical = HeaderVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - HeaderButton( - icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - Column(modifier = Modifier.weight(1f)) { - Text(text = "Blackjack", color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon( - imageVector = Icons.Outlined.Shield, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(SubtitleIconSize), - ) - Text( - text = "Provably Fair · Classic · RTP 99.5%", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } - HeaderButton( - icon = Icons.Outlined.IosShare, - contentDescription = "Share last round", - onClick = { context.shareText(SHARE_MESSAGE) }, - ) - HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) - } -} - -@Composable -private fun HeaderButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(HeaderButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = TextHigh, - modifier = Modifier.size(HeaderIconSize), - ) - } -} - @Composable private fun RecentStrip(results: List) { Column( @@ -216,7 +149,7 @@ private fun HistoryChip(result: BlackjackResult) { @Composable private fun StatsStrip(state: BlackjackUiState) { - val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk + val profitColor = if (state.sessionProfitPositive) SemanticOk else SemanticDanger Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -237,26 +170,6 @@ private fun StatsStrip(state: BlackjackUiState) { } } -@Composable -private fun StatCell( - label: String, - value: String, - valueColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) - } -} - @Composable private fun Table( state: BlackjackUiState, @@ -272,8 +185,8 @@ private fun Table( val centerColor = when { state.phase != BlackjackPhase.Result -> TextLow - state.resultLabel.contains("+") -> SemanticOk - state.resultLabel.contains("-") -> SemanticDanger + state.resultTone == OutcomeTone.Win -> SemanticOk + state.resultTone == OutcomeTone.Loss -> SemanticDanger else -> TextMedium } Text( @@ -391,7 +304,7 @@ private fun BlackjackControls( onDouble = onDouble, ) BlackjackPhase.Result -> - PrimaryButton(label = "New Bet", onClick = onNewBet) + GameActionButton(label = "New Bet", onClick = onNewBet) BlackjackPhase.Idle -> { Text( text = "Bet Amount", @@ -410,7 +323,7 @@ private fun BlackjackControls( ) } } - PrimaryButton(label = "DEAL", onClick = onDeal) + GameActionButton(label = "DEAL", onClick = onDeal) } } } @@ -551,40 +464,6 @@ private fun ActionButton( } } -@Composable -private fun PrimaryButton( - label: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .background(AccentViolet) - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -/** Opens the system share sheet with [text]; no-op if nothing handles it. */ -private fun Context.shareText(text: String) { - val send = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - runCatching { startActivity(Intent.createChooser(send, null)) } -} - private fun rankLabel(card: Int): String = RANK_LABELS[BlackjackMath.rank(card)] private fun suitGlyph(card: Int): String = SUIT_GLYPHS[BlackjackMath.suit(card)] @@ -600,29 +479,15 @@ private const val SHARE_MESSAGE = "Blackjack on Stack Casino - provably fair, classic, RTP 99.5%." private val BET_PRESETS = listOf(5, 10, 25, 100) -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val BetTextStyle = TextStyle(color = TextHigh, fontSize = 14.sp, fontFeatureSettings = "tnum") -// Drops the font's vertical padding so the W / L / P glyph sits centered in the small chips. -private val CenteredGlyph = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) - -private val ScreenHorizontalPadding = 16.dp -private val HeaderVerticalPadding = 16.dp private val ControlsVerticalPadding = 12.dp -private val HeaderButtonSize = 36.dp -private val HeaderIconSize = 16.dp -private val SubtitleIconSize = 10.dp private val HistoryChipSize = 20.dp private val HandMinHeight = 72.dp private val CardWidth = 44.dp private val CardHeight = 62.dp -private val TinyFontSize = 9.sp -private val MetaFontSize = 10.sp -private val TrackedLetterSpacing = 1.2.sp - private const val PRESET_SELECTED_ALPHA = 0.10f -private const val DISABLED_ALPHA = 0.40f private const val ACTION_FILL_ALPHA = 0.10f private const val ACTION_BORDER_ALPHA = 0.50f private const val CARD_BACK_ALPHA = 0.30f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt index 61986fe..3da1af5 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt @@ -1,5 +1,7 @@ package com.plainstudio.stackcasino.feature.blackjack +import com.plainstudio.stackcasino.ui.components.OutcomeTone + /** Lifecycle of a blackjack round. */ enum class BlackjackPhase { /** No hand dealt; the bet is editable and Deal is available. */ @@ -37,6 +39,7 @@ data class BlackjackUiState( val betText: String = DEFAULT_BET, val balanceLabel: String = "$0.00", val sessionProfitLabel: String = "+$0.00", + val sessionProfitPositive: Boolean = true, val recentResults: List = emptyList(), val dealerCards: List = emptyList(), val playerCards: List = emptyList(), @@ -44,6 +47,7 @@ data class BlackjackUiState( val playerTotalLabel: String = "-", val statusLabel: String = "Place a bet and deal", val resultLabel: String = "", + val resultTone: OutcomeTone = OutcomeTone.Push, val canHit: Boolean = false, val canStand: Boolean = false, val canDouble: Boolean = false, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt index 4b95f37..eab24e6 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt @@ -9,6 +9,9 @@ import com.plainstudio.stackcasino.domain.game.GameRepository import com.plainstudio.stackcasino.domain.game.GameRound import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.ui.components.OutcomeTone +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -68,8 +71,9 @@ class BlackjackViewModel availableBalance = stats.balance _state.update { it.copy( - balanceLabel = "$" + money(stats.balance), + balanceLabel = "$" + formatMoney(stats.balance), sessionProfitLabel = signedMoney(stats.profit), + sessionProfitPositive = stats.profit >= 0.0, recentResults = stats.recent, ) } @@ -117,8 +121,8 @@ class BlackjackViewModel fun onDouble() { if (_state.value.phase != BlackjackPhase.PlayerTurn || - player.size != 2 || - availableBalance < effectiveStake * 2 + player.size != OPENING_CARDS || + availableBalance < effectiveStake * DOUBLE_FACTOR ) { return } @@ -170,7 +174,7 @@ class BlackjackViewModel if (BlackjackMath.isBlackjack(player) || BlackjackMath.isBlackjack(dealer)) { finish(playerBust = false) } else { - renderPlayerTurn(canDouble = availableBalance >= stake * 2) + renderPlayerTurn(canDouble = availableBalance >= stake * DOUBLE_FACTOR) } } @@ -270,6 +274,12 @@ class BlackjackViewModel playerTotalLabel = playerTotal.toString(), statusLabel = "", resultLabel = if (net == 0.0) label else "$label ${signedMoney(net)}", + resultTone = + when { + net > 0.0 -> OutcomeTone.Win + net < 0.0 -> OutcomeTone.Loss + else -> OutcomeTone.Push + }, canHit = false, canStand = false, canDouble = false, @@ -286,10 +296,6 @@ class BlackjackViewModel else -> BlackjackResult.Loss } - 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)) - private data class Stats( val balance: Double, val recent: List, @@ -302,5 +308,11 @@ class BlackjackViewModel const val BLACKJACK_PAYOUT = 2.5 const val DEAL_MS = 380L const val REVEAL_MS = 550L + + /** A fresh hand is dealt two cards; doubling can only happen then. */ + const val OPENING_CARDS = 2 + + /** Doubling down stakes twice the original bet. */ + const val DOUBLE_FACTOR = 2 } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipRulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipRulesSheet.kt index 2d6b532..3c2c01e 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipRulesSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipRulesSheet.kt @@ -1,105 +1,20 @@ package com.plainstudio.stackcasino.feature.coinflip -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.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 +import com.plainstudio.stackcasino.ui.components.RulesSheet /** - * Coinflip rules sheet reached from the header. Styled after the - * mockup's legal sheets (Terms / Privacy): a tracked section label, a - * bold title and a meta line, over numbered rule sections. + * Coinflip rules sheet reached from the header. Thin wrapper over the + * shared [RulesSheet]; only the title, meta line and rule data differ. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CoinflipRulesSheet(onDismiss: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = SurfaceRaised, - contentColor = TextHigh, - ) { - Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), - ) { - Text( - text = "How to Play", - color = AccentViolet, - fontSize = LabelFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "Coinflip Rules", color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "50 / 50 · x2 Payout · RTP 100%", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TabularNums, - ) - } - Divider() - Column( - modifier = Modifier.fillMaxWidth().padding(SheetPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - RULES.forEach { (label, body) -> RuleSection(label = label, body = body) } - Spacer(modifier = Modifier.height(4.dp)) - Divider() - Text( - text = "End of rules · Must be 18+ to play", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), - ) - } - } - } -} - -@Composable -private fun RuleSection( - label: String, - body: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) - } -} - -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + RulesSheet( + title = "Coinflip Rules", + meta = "50 / 50 · x2 Payout · RTP 100%", + rules = RULES, + onDismiss = onDismiss, + ) } private val RULES = @@ -116,9 +31,3 @@ private val RULES = "The result is derived from HMAC-SHA256(server_seed, client_seed:nonce) mod 2. You can verify any " + "round after it settles.", ) - -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") -private val SheetPadding = 20.dp -private val LabelFontSize = 10.sp -private val MetaFontSize = 9.sp -private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreen.kt index 7d64746..828a4af 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreen.kt @@ -1,7 +1,5 @@ package com.plainstudio.stackcasino.feature.coinflip -import android.content.Context -import android.content.Intent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -23,12 +21,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft -import androidx.compose.material.icons.outlined.IosShare -import androidx.compose.material.icons.outlined.MenuBook -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 @@ -43,26 +35,33 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.PlatformTextStyle -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.domain.game.CoinSide +import com.plainstudio.stackcasino.ui.components.GameActionButton +import com.plainstudio.stackcasino.ui.components.GameHeader +import com.plainstudio.stackcasino.ui.components.StatCell import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft +import com.plainstudio.stackcasino.ui.theme.CHIP_BORDER_ALPHA +import com.plainstudio.stackcasino.ui.theme.CHIP_TINT_ALPHA +import com.plainstudio.stackcasino.ui.theme.CenteredGlyph +import com.plainstudio.stackcasino.ui.theme.MetaFontSize +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding 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.TabularNums import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing /** * Coinflip game screen (mockup/js/screens/coinflip.js). @@ -86,7 +85,13 @@ fun CoinflipScreen( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { - CoinflipHeader(onBack = onBack, onOpenRules = { showRules = true }) + GameHeader( + title = "Coinflip", + subtitle = "Provably Fair · 50/50 · x2", + shareMessage = SHARE_MESSAGE, + onBack = onBack, + onOpenRules = { showRules = true }, + ) RecentStrip(results = state.recentResults) StatsStrip(state = state) CoinStage(modifier = Modifier.weight(1f), state = state) @@ -106,75 +111,6 @@ fun CoinflipScreen( } } -@Composable -private fun CoinflipHeader( - onBack: () -> Unit, - onOpenRules: () -> Unit, -) { - val context = LocalContext.current - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = HeaderVerticalPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - HeaderButton( - icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - Column(modifier = Modifier.weight(1f)) { - Text(text = "Coinflip", color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon( - imageVector = Icons.Outlined.Shield, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(SubtitleIconSize), - ) - Text( - text = "Provably Fair · 50/50 · x2", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } - HeaderButton( - icon = Icons.Outlined.IosShare, - contentDescription = "Share last round", - onClick = { context.shareText(SHARE_MESSAGE) }, - ) - HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) - } -} - -@Composable -private fun HeaderButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(HeaderButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = TextHigh, - modifier = Modifier.size(HeaderIconSize), - ) - } -} - @Composable private fun RecentStrip(results: List) { Column( @@ -220,7 +156,7 @@ private fun RecentChip(won: Boolean) { @Composable private fun StatsStrip(state: CoinflipUiState) { - val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk + val profitColor = if (state.sessionProfitPositive) SemanticOk else SemanticDanger Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -241,27 +177,6 @@ private fun StatsStrip(state: CoinflipUiState) { } } -@Composable -private fun StatCell( - label: String, - value: String, - valueColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .background( - SurfaceRaised, - ).border(width = 1.dp, color = SurfaceOutline) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) - } -} - @Composable private fun CoinStage( state: CoinflipUiState, @@ -419,7 +334,7 @@ private fun CoinflipControls( ) } } - FlipButton(enabled = canFlip, onClick = onFlip) + GameActionButton(label = "FLIP", onClick = onFlip, enabled = canFlip) } } @@ -493,68 +408,17 @@ private fun PresetButton( } } -@Composable -private fun FlipButton( - enabled: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .background(AccentViolet.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) - .clickable(enabled = enabled, onClick = onClick) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "FLIP", - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -/** Opens the system share sheet with [text]; no-op if nothing handles it. */ -private fun Context.shareText(text: String) { - val send = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - runCatching { startActivity(Intent.createChooser(send, null)) } -} - private const val SHARE_MESSAGE = "Coinflip on Stack Casino - provably fair, 50/50, x2 payout." private val BET_PRESETS = listOf(5, 10, 25, 100) -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") - -// Drops the font's vertical padding so a single uppercase glyph sits -// centered inside the small result chips. -private val CenteredGlyph = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) -private val ScreenHorizontalPadding = 16.dp -private val HeaderVerticalPadding = 16.dp private val ControlsVerticalPadding = 12.dp -private val HeaderButtonSize = 36.dp -private val HeaderIconSize = 16.dp -private val SubtitleIconSize = 10.dp private val RecentChipSize = 20.dp private val CoinSize = 180.dp private val CoinLabelOffset = 60.dp -private val TinyFontSize = 9.sp -private val MetaFontSize = 10.sp -private val TrackedLetterSpacing = 1.2.sp - -private const val CHIP_TINT_ALPHA = 0.15f -private const val CHIP_BORDER_ALPHA = 0.40f private const val PICK_SELECTED_ALPHA = 0.10f -private const val DISABLED_ALPHA = 0.40f private const val INNER_RING_ALPHA = 0.22f private const val INNER_RING_FRACTION = 0.87f private const val EMBLEM_OUTER_FRACTION = 0.39f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipUiState.kt index 9b9f524..ae35ffb 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipUiState.kt @@ -32,6 +32,7 @@ data class CoinflipUiState( val betAmount: Int = DEFAULT_BET, val balanceLabel: String = "$0.00", val sessionProfitLabel: String = "+$0.00", + val sessionProfitPositive: Boolean = true, val recentResults: List = emptyList(), val lastResult: CoinflipResult? = null, ) { diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipViewModel.kt index 13f03a3..8e5eefa 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipViewModel.kt @@ -9,6 +9,8 @@ import com.plainstudio.stackcasino.domain.game.PlayResult import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -20,7 +22,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.Locale import javax.inject.Inject /** @@ -53,6 +54,7 @@ class CoinflipViewModel it.copy( balanceLabel = "$" + formatMoney(balance), sessionProfitLabel = signedMoney(netProfit), + sessionProfitPositive = netProfit >= 0.0, recentResults = coinflipRounds.take(RECENT_COUNT).map { round -> round.outcome == RoundOutcome.Win @@ -102,11 +104,6 @@ class CoinflipViewModel private fun List.netProfit(): Double = sumOf { it.netProfit } - 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 RECENT_COUNT = 4 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashRulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashRulesSheet.kt index 7649c65..f7eb8f2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashRulesSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashRulesSheet.kt @@ -1,105 +1,20 @@ package com.plainstudio.stackcasino.feature.crash -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.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 +import com.plainstudio.stackcasino.ui.components.RulesSheet /** - * Crash rules sheet reached from the header, styled after the mockup's - * legal sheets: a tracked label, a bold title and a meta line over the - * numbered rule sections. + * Crash rules sheet reached from the header. Thin wrapper over the + * shared [RulesSheet]; only the title, meta line and rule data differ. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CrashRulesSheet(onDismiss: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = SurfaceRaised, - contentColor = TextHigh, - ) { - Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), - ) { - Text( - text = "How to Play", - color = AccentViolet, - fontSize = LabelFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "Crash Rules", color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Exponential Multiplier · RTP 99%", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TabularNums, - ) - } - Divider() - Column( - modifier = Modifier.fillMaxWidth().padding(SheetPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - RULES.forEach { (label, body) -> RuleSection(label = label, body = body) } - Spacer(modifier = Modifier.height(4.dp)) - Divider() - Text( - text = "End of rules · Must be 18+ to play", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), - ) - } - } - } -} - -@Composable -private fun RuleSection( - label: String, - body: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) - } -} - -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + RulesSheet( + title = "Crash Rules", + meta = "Exponential Multiplier · RTP 99%", + rules = RULES, + onDismiss = onDismiss, + ) } private val RULES = @@ -123,9 +38,3 @@ private val RULES = "Crash points are derived from HMAC-SHA256(server_seed, client_seed:nonce), with a 1% house edge " + "applied. You can verify any round from the round detail screen after the game.", ) - -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") -private val SheetPadding = 20.dp -private val LabelFontSize = 10.sp -private val MetaFontSize = 9.sp -private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt index ab6b15c..2790e3d 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt @@ -1,7 +1,5 @@ package com.plainstudio.stackcasino.feature.crash -import android.content.Context -import android.content.Intent import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,12 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -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.MenuBook -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 @@ -40,25 +32,33 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.GameActionButton +import com.plainstudio.stackcasino.ui.components.GameHeader +import com.plainstudio.stackcasino.ui.components.StatCell +import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.MetaFontSize +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding 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.TabularNums import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium -import java.util.Locale +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.formatMultiplier import kotlin.math.ln import kotlin.math.pow @@ -87,7 +87,13 @@ fun CrashScreen( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { - CrashHeader(onBack = onBack, onOpenRules = { showRules = true }) + GameHeader( + title = "Crash", + subtitle = "Provably Fair · Exponential", + shareMessage = SHARE_MESSAGE, + onBack = onBack, + onOpenRules = { showRules = true }, + ) RecentStrip(results = state.recentResults) StatsStrip(state = state, multiplier = multiplier) CrashChart(state = state, multiplier = multiplier, modifier = Modifier.weight(1f)) @@ -109,76 +115,6 @@ fun CrashScreen( } } -@Composable -private fun CrashHeader( - onBack: () -> Unit, - onOpenRules: () -> Unit, -) { - val context = LocalContext.current - Row( - modifier = - Modifier.fillMaxWidth().padding( - horizontal = ScreenHorizontalPadding, - vertical = HeaderVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - HeaderButton( - icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - Column(modifier = Modifier.weight(1f)) { - Text(text = "Crash", color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon( - imageVector = Icons.Outlined.Shield, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(SubtitleIconSize), - ) - Text( - text = "Provably Fair · Exponential", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } - HeaderButton( - icon = Icons.Outlined.IosShare, - contentDescription = "Share last round", - onClick = { context.shareText(SHARE_MESSAGE) }, - ) - HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) - } -} - -@Composable -private fun HeaderButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(HeaderButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = TextHigh, - modifier = Modifier.size(HeaderIconSize), - ) - } -} - @Composable private fun RecentStrip(results: List) { Column( @@ -218,7 +154,7 @@ private fun StatsStrip( ) { val bet = state.betText.toDoubleOrNull() ?: 0.0 val potential = if (state.isRunning) bet * multiplier else bet - val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk + val profitColor = if (state.sessionProfitPositive) SemanticOk else SemanticDanger Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -232,33 +168,13 @@ private fun StatsStrip( ) StatCell( label = "Potential", - value = "$" + money(potential), + value = "$" + formatMoney(potential), valueColor = SemanticOk, modifier = Modifier.weight(1f), ) } } -@Composable -private fun StatCell( - label: String, - value: String, - valueColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) - } -} - @Composable private fun CrashChart( state: CrashUiState, @@ -283,10 +199,10 @@ private fun CrashChart( .fillMaxWidth() .padding(horizontal = ScreenHorizontalPadding, vertical = 8.dp) .background(SurfaceBase.copy(alpha = CHART_BACKGROUND_ALPHA)) + .gridBackground() .border(width = 1.dp, color = SurfaceOutline), contentAlignment = Alignment.Center, ) { - GridBackground() CurveCanvas(multiplier = multiplier, color = curveColor) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -339,26 +255,6 @@ private fun CurveCanvas( } } -/** Faint 24dp grid evoking the mockup's `.grid-bg` chart backdrop. */ -@Composable -private fun GridBackground() { - Canvas(modifier = Modifier.fillMaxSize()) { - val step = GridCellSize.toPx() - val line = Color.White.copy(alpha = GRID_LINE_ALPHA) - val stroke = 1.dp.toPx() - var x = step - while (x < size.width) { - drawLine(color = line, start = Offset(x, 0f), end = Offset(x, size.height), strokeWidth = stroke) - x += step - } - var y = step - while (y < size.height) { - drawLine(color = line, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = stroke) - y += step - } - } -} - @Composable private fun CrashControls( state: CrashUiState, @@ -381,10 +277,10 @@ private fun CrashControls( when (state.phase) { CrashPhase.Running -> { val bet = state.betText.toDoubleOrNull() ?: 0.0 - ActionButton( - label = "Cash Out $" + money(bet * multiplier), - background = SemanticOk, + GameActionButton( + label = "Cash Out $" + formatMoney(bet * multiplier), onClick = onCashout, + background = SemanticOk, ) } CrashPhase.CashedOut, CrashPhase.Crashed -> { @@ -395,7 +291,7 @@ private fun CrashControls( fontWeight = FontWeight.Bold, letterSpacing = TrackedLetterSpacing, ) - ActionButton(label = "New Bet", background = AccentViolet, onClick = onNewBet) + GameActionButton(label = "New Bet", onClick = onNewBet, background = AccentViolet) } CrashPhase.Idle -> SetupControls( @@ -447,7 +343,7 @@ private fun SetupControls( modifier = Modifier.width(AutoCashoutWidth), ) } - ActionButton(label = "Place Bet", background = AccentViolet, onClick = onPlaceBet) + GameActionButton(label = "Place Bet", onClick = onPlaceBet, background = AccentViolet) } @Composable @@ -536,51 +432,11 @@ private fun AutoCashoutField( } } -@Composable -private fun ActionButton( - label: String, - background: Color, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .background(background) - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -/** Opens the system share sheet with [text]; no-op if nothing handles it. */ -private fun Context.shareText(text: String) { - val send = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - runCatching { startActivity(Intent.createChooser(send, null)) } -} - -private fun money(value: Double): String = String.format(Locale.US, "%,.2f", value) - -private fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "×" - private const val SHARE_MESSAGE = "Crash on Stack Casino - provably fair, exponential multiplier, RTP 99%." private val BET_PRESETS = listOf(5, 10, 25, 100) -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val BetTextStyle = TextStyle(color = TextHigh, fontSize = 14.sp, fontFeatureSettings = "tnum") private val AutoTextStyle = TextStyle(color = TextHigh, fontSize = 11.sp, fontFeatureSettings = "tnum") private val HeroStyle = @@ -589,23 +445,13 @@ private val HeroStyle = shadow = Shadow(color = Color.Black.copy(alpha = HERO_SHADOW_ALPHA), offset = Offset(0f, 8f), blurRadius = 20f), ) -private val ScreenHorizontalPadding = 16.dp -private val HeaderVerticalPadding = 16.dp private val ControlsVerticalPadding = 12.dp -private val HeaderButtonSize = 36.dp -private val HeaderIconSize = 16.dp -private val SubtitleIconSize = 10.dp private val RecentChipSize = 12.dp -private val GridCellSize = 24.dp private val AutoCashoutWidth = 84.dp -private val TinyFontSize = 9.sp -private val MetaFontSize = 10.sp private val HeroFontSize = 56.sp -private val TrackedLetterSpacing = 1.2.sp private const val PRESET_SELECTED_ALPHA = 0.10f -private const val GRID_LINE_ALPHA = 0.035f private const val CHART_BACKGROUND_ALPHA = 0.40f private const val HERO_SHADOW_ALPHA = 0.65f private const val CHART_MAX = 20.0 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt index 344920f..f005060 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt @@ -27,6 +27,7 @@ data class CrashUiState( val autoText: String = "", val balanceLabel: String = "$0.00", val sessionProfitLabel: String = "+$0.00", + val sessionProfitPositive: Boolean = true, val recentResults: List = emptyList(), val statusLabel: String = "Set your bet to begin", val resultLabel: String = "", diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt index 4c7fbdf..6b8f08e 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt @@ -9,6 +9,9 @@ import com.plainstudio.stackcasino.domain.game.GameRepository import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.formatMultiplier +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -67,8 +70,9 @@ class CrashViewModel }.collect { stats -> _state.update { it.copy( - balanceLabel = "$" + money(stats.balance), + balanceLabel = "$" + formatMoney(stats.balance), sessionProfitLabel = signedMoney(stats.profit), + sessionProfitPositive = stats.profit >= 0.0, recentResults = stats.recent, ) } @@ -195,12 +199,6 @@ class CrashViewModel _events.tryEmit(message) } - private fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "x" - - 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)) - private data class Stats( val balance: Double, val recent: List, 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 index 455d780..1c0bb6a 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt @@ -12,15 +12,13 @@ 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.requiredHeight 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 @@ -34,7 +32,6 @@ 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 @@ -44,10 +41,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.model.displayName 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.HairlineDivider +import com.plainstudio.stackcasino.ui.components.SearchField import com.plainstudio.stackcasino.ui.components.StackCard +import com.plainstudio.stackcasino.ui.components.VerticalHairline import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet @@ -91,7 +92,7 @@ fun HistoryScreen( .verticalScroll(rememberScrollState()), ) { HistoryHeader() - HorizontalDivider() + HairlineDivider() SearchInput(value = query, onValueChange = { query = it }) FiltersBlock( gameFilter = gameFilter, @@ -138,47 +139,13 @@ private fun SearchInput( value: String, onValueChange: (String) -> Unit, ) { - Row( + SearchField( + value = value, + onValueChange = onValueChange, + placeholder = "Search rounds...", 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, - ) - } - } - } + Modifier.padding(horizontal = ScreenHorizontalPadding, vertical = SearchVerticalPadding), + ) } @Composable @@ -252,14 +219,14 @@ private fun SummaryStrip(summary: HistorySummary) { valueColor = TextHigh, modifier = Modifier.weight(1f), ) - VerticalDivider() + VerticalHairline(modifier = Modifier.requiredHeight(SummaryDividerHeight)) SummaryCell( label = "Win Rate", value = "${summary.winRatePercent}%", valueColor = SemanticOk, modifier = Modifier.weight(1f), ) - VerticalDivider() + VerticalHairline(modifier = Modifier.requiredHeight(SummaryDividerHeight)) SummaryCell( label = "Net", value = summary.netLabel, @@ -331,13 +298,7 @@ private fun RoundCard( Column { RoundHeader(round = round) Spacer(modifier = Modifier.height(12.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(SurfaceOutline), - ) + HairlineDivider() Spacer(modifier = Modifier.height(12.dp)) RoundMetricsRow(round = round) } @@ -362,7 +323,7 @@ private fun RoundHeader(round: HistoryRound) { } Column(modifier = Modifier.weight(1f)) { Text( - text = round.game.label(), + text = round.game.displayName, color = TextHigh, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, @@ -495,16 +456,6 @@ private fun EndOfResultsSentinel() { } } -@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 // --------------------------------------------------------------------------- @@ -518,21 +469,12 @@ private fun HistoryRound.matches( 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) || + return game.displayName.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" - } - // --------------------------------------------------------------------------- // Tokens // --------------------------------------------------------------------------- @@ -544,10 +486,6 @@ 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 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt index 7a5d5e9..209d5a1 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt @@ -6,13 +6,14 @@ 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.TimestampFormat +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.signedMoney 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 @@ -54,7 +55,7 @@ class HistoryViewModel HistoryRound( id = id, game = game, - timestampLabel = TIMESTAMP_FORMAT.format(Date(timestamp)), + timestampLabel = TimestampFormat.ledger(timestamp), betLabel = "$" + formatMoney(stake), payoutLabel = "$" + formatMoney(payout), multiplierLabel = if (game == GameKey.Coinflip) pick else multiplierLabel(), @@ -67,15 +68,9 @@ class HistoryViewModel 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"), diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt index 6f603eb..f3c91ee 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.ui.components.LocalToastController import com.plainstudio.stackcasino.ui.components.ToastData import com.plainstudio.stackcasino.ui.components.ToastType +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk @@ -78,7 +79,7 @@ internal fun HouseWalletActiveBody( private fun BalanceCard() { val toast = LocalToastController.current Row( - modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding), + modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top, ) { @@ -124,7 +125,7 @@ private fun AddressCard() { val context = LocalContext.current val clipboard = LocalClipboardManager.current val toast = LocalToastController.current - Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -191,7 +192,7 @@ private fun PrivateKeyCard( onRevealRequest: () -> Unit, onHideKey: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -267,7 +268,7 @@ private fun RevealedKey( modifier = Modifier.size(InlineIconSize), ) Text( - text = "Hides in 10s", + text = "Hides in ${REVEAL_SECONDS}s", color = SemanticDanger, fontSize = SmallMetaFontSize, letterSpacing = TrackedLetterSpacing, @@ -341,7 +342,7 @@ private fun ActionButtons() { @Composable private fun RecentActivityCard() { - Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { SectionLabel(text = "Recent Activity") Spacer(modifier = Modifier.height(12.dp)) ActivityRow( diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt index ed5ff4a..f71a105 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.VerticalHairline import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.SurfaceRaised @@ -91,7 +93,7 @@ internal fun HouseWalletBiometricPrompt( HairlineDivider() Row(modifier = Modifier.fillMaxWidth()) { PromptAction(label = "Cancel", color = TextMedium, onClick = onCancel, modifier = Modifier.weight(1f)) - Box(modifier = Modifier.width(1.dp).height(PromptActionHeight).background(SurfaceOutline)) + VerticalHairline(modifier = Modifier.height(PromptActionHeight)) PromptAction( label = "Use PIN", color = AccentViolet, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt index 1dc2af2..cd3dfc4 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt @@ -37,6 +37,7 @@ 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.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme import com.plainstudio.stackcasino.ui.theme.SurfaceBase @@ -208,13 +209,6 @@ private fun BiometricBadge() { // Shared building blocks for the house wallet section files // --------------------------------------------------------------------------- -internal fun Modifier.hwCardSurface(): Modifier = background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) - -@Composable -internal fun HairlineDivider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) -} - @Composable internal fun SectionLabel( text: String, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt index 0196171..d883c79 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt @@ -30,6 +30,8 @@ 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.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft import com.plainstudio.stackcasino.ui.theme.SemanticDanger @@ -51,7 +53,7 @@ internal fun HouseWalletUnsetBody(onCreateWallet: () -> Unit) { @Composable private fun IntroCard() { - Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Text( text = "Configure the wallet that will act as the house. This wallet funds player " + @@ -87,7 +89,7 @@ private fun GenerateCard(onCreate: () -> Unit) { modifier = Modifier .fillMaxWidth() - .hwCardSurface() + .cardSurface() .clickable(onClick = onCreate) .padding(CardLargePadding), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -126,7 +128,7 @@ private fun GenerateCard(onCreate: () -> Unit) { @Composable private fun ImportCard(onImport: () -> Unit) { - Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardLargePadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardLargePadding)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { ChoiceIcon(icon = Icons.Outlined.VpnKey) Column(modifier = Modifier.weight(1f)) { diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt index f3c15b1..c84e410 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt @@ -36,6 +36,8 @@ 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.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme @@ -205,7 +207,7 @@ private fun KycFormBody( @Composable private fun IntroCard() { - Column(modifier = Modifier.fillMaxWidth().kycCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Text( text = "To comply with regulations and unlock withdrawals over $100, upload clear " + @@ -317,14 +319,6 @@ private fun SubmitCta( // Shared building blocks for the KYC section files // --------------------------------------------------------------------------- -internal fun Modifier.kycCardSurface(): Modifier = - background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) - -@Composable -internal fun HairlineDivider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) -} - internal val ScreenHorizontalPadding = 16.dp internal val SectionGap = 16.dp internal val CardPadding = 16.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt index b20648b..528a1b2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt @@ -34,6 +34,7 @@ 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.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SurfaceBase @@ -56,7 +57,7 @@ internal fun KycSlot( onPick: () -> Unit, onReplace: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().kycCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { SlotHeader(title = title) Spacer(modifier = Modifier.height(12.dp)) when (status) { @@ -173,7 +174,7 @@ private fun UploadingView(fileName: String) { } Spacer(modifier = Modifier.height(4.dp)) Text( - text = "72% · $fileName · 2.1 MB", + text = "${(UPLOAD_PROGRESS_FRACTION * PERCENT).toInt()}% · $fileName · 2.1 MB", color = TextLow, fontSize = SmallMetaFontSize, letterSpacing = TrackedLetterSpacing, @@ -343,6 +344,7 @@ private val DocBackGradientBottom = Color(0xFF15141F) private const val SLOT_ASPECT_RATIO = 1.6f private const val UPLOAD_TRACK_FRACTION = 0.66f private const val UPLOAD_PROGRESS_FRACTION = 0.72f +private const val PERCENT = 100 private const val UPLOADING_TINT_ALPHA = 0.05f private const val UPLOADING_BORDER_ALPHA = 0.40f private const val DOC_LABEL_ALPHA = 0.5f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt index 5c53021..1f0110f 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SurfaceOutline @@ -91,7 +92,7 @@ private fun GameCard( Box( modifier = modifier - .lobbyCardSurface(fill = background, stroke = borderColor) + .cardSurface(fill = background, stroke = borderColor) .clickable(onClick = onClick), ) { Column(modifier = Modifier.padding(GameCardPadding)) { @@ -177,7 +178,7 @@ private fun CoinflipCard( modifier = Modifier .fillMaxWidth() - .lobbyCardSurface() + .cardSurface() .clickable(onClick = onClick) .padding(GameCardPadding), ) { diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt index 4820948..664c967 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.Skeleton /** @@ -44,7 +45,7 @@ internal fun LoadingContent() { Skeleton(modifier = Modifier.size(width = 80.dp, height = 8.dp)) } } - Divider() + HairlineDivider() // Games skeleton. Column( modifier = @@ -76,7 +77,7 @@ internal fun LoadingContent() { } Skeleton(modifier = Modifier.fillMaxWidth().height(CoinflipSkeletonHeight)) } - Divider() + HairlineDivider() // Recent skeleton. Column( modifier = diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt index 7086756..b189da0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SemanticWarn import com.plainstudio.stackcasino.ui.theme.TextHigh @@ -90,7 +91,7 @@ private fun QuickActionCard( Box( modifier = modifier - .lobbyCardSurface() + .cardSurface() .clickable(onClick = onClick) .padding(GameCardPadding), ) { 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 f3b7e89..8d24172 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 @@ -1,7 +1,6 @@ package com.plainstudio.stackcasino.feature.lobby import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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 @@ -23,7 +21,6 @@ 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.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview @@ -34,12 +31,11 @@ import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.navigation.Route import com.plainstudio.stackcasino.ui.components.ErrorState import com.plainstudio.stackcasino.ui.components.ErrorStateDefaults +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.AccentViolet 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 /** * Lobby screen reproducing the cu-03 mockup @@ -147,24 +143,24 @@ private fun SuccessContent( hasUnreadNotifications = hasUnreadNotifications, onOpenNotifications = onOpenNotifications, ) - Divider() + HairlineDivider() BalanceHero( balance = data.balance, session = data.session, isHidden = isBalanceHidden, onToggleHidden = { isBalanceHidden = !isBalanceHidden }, ) - Divider() + HairlineDivider() GamesSection( games = data.games, onSelectGame = { onNavigate(it.toRoute()) }, ) - Divider() + HairlineDivider() QuickActionsSection( onOpenDeposit = { onOpenWallet(QUICK_ACTION_TAB_DEPOSIT) }, onOpenWithdraw = { onOpenWallet(QUICK_ACTION_TAB_WITHDRAW) }, ) - Divider() + HairlineDivider() RecentActivitySection( rounds = data.recentActivity, onViewAll = { onNavigate(Route.History) }, @@ -200,28 +196,6 @@ private fun ErrorContent( // Shared building blocks // --------------------------------------------------------------------------- -/** - * Common surface for the lobby's tappable cards: the graphite fill plus - * the hairline outline both the games grid and quick actions repeat. - * Callers add `.clickable` and content padding themselves because those - * differ per card (the games grid insets the badge separately). - */ -internal fun Modifier.lobbyCardSurface( - fill: Color = SurfaceRaised, - stroke: Color = SurfaceOutline, -): Modifier = background(fill).border(width = 1.dp, color = stroke) - -@Composable -internal fun Divider() { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(SurfaceOutline), - ) -} - @Composable private fun NepFab( onClick: () -> Unit, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesRulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesRulesSheet.kt index 6ff705e..f46a473 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesRulesSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesRulesSheet.kt @@ -1,105 +1,20 @@ package com.plainstudio.stackcasino.feature.mines -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.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 +import com.plainstudio.stackcasino.ui.components.RulesSheet /** - * Mines rules sheet reached from the header, styled after the mockup's - * legal sheets: a tracked label, a bold title and a meta line over the - * numbered rule sections. + * Mines rules sheet reached from the header. Thin wrapper over the + * shared [RulesSheet]; only the title, meta line and rule data differ. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MinesRulesSheet(onDismiss: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = SurfaceRaised, - contentColor = TextHigh, - ) { - Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), - ) { - Text( - text = "How to Play", - color = AccentViolet, - fontSize = LabelFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "Mines Rules", color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "5x5 Grid · RTP 99%", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TabularNums, - ) - } - Divider() - Column( - modifier = Modifier.fillMaxWidth().padding(SheetPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - RULES.forEach { (label, body) -> RuleSection(label = label, body = body) } - Spacer(modifier = Modifier.height(4.dp)) - Divider() - Text( - text = "End of rules · Must be 18+ to play", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), - ) - } - } - } -} - -@Composable -private fun RuleSection( - label: String, - body: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) - } -} - -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + RulesSheet( + title = "Mines Rules", + meta = "5x5 Grid · RTP 99%", + rules = RULES, + onDismiss = onDismiss, + ) } private val RULES = @@ -122,9 +37,3 @@ private val RULES = "Mine positions are derived from HMAC-SHA256(server_seed, client_seed:nonce). You can verify the " + "outcome from the round detail screen after the round.", ) - -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") -private val SheetPadding = 20.dp -private val LabelFontSize = 10.sp -private val MetaFontSize = 9.sp -private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt index 4d64f72..bffc0fe 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt @@ -1,7 +1,5 @@ package com.plainstudio.stackcasino.feature.mines -import android.content.Context -import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -9,7 +7,6 @@ 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.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -18,12 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -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.MenuBook -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 @@ -36,15 +27,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.PlatformTextStyle -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.ui.components.GameActionButton +import com.plainstudio.stackcasino.ui.components.GameHeader +import com.plainstudio.stackcasino.ui.components.StatCell import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.CHIP_BORDER_ALPHA +import com.plainstudio.stackcasino.ui.theme.CHIP_TINT_ALPHA +import com.plainstudio.stackcasino.ui.theme.CenteredGlyph +import com.plainstudio.stackcasino.ui.theme.MetaFontSize +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SemanticWarn @@ -52,9 +47,12 @@ 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.TabularNums import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing /** * Mines game screen (mockup/js/screens/mines.js). @@ -81,7 +79,13 @@ fun MinesScreen( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { - MinesHeader(onBack = onBack, onOpenRules = { showRules = true }) + GameHeader( + title = "Mines", + subtitle = "Provably Fair · 5x5 Grid", + shareMessage = SHARE_MESSAGE, + onBack = onBack, + onOpenRules = { showRules = true }, + ) RecentStrip(results = state.recentResults) StatsStrip(state = state) MinesGrid(state = state, onRevealTile = onRevealTile, modifier = Modifier.weight(1f)) @@ -101,76 +105,6 @@ fun MinesScreen( } } -@Composable -private fun MinesHeader( - onBack: () -> Unit, - onOpenRules: () -> Unit, -) { - val context = LocalContext.current - Row( - modifier = - Modifier.fillMaxWidth().padding( - horizontal = ScreenHorizontalPadding, - vertical = HeaderVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - HeaderButton( - icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - Column(modifier = Modifier.weight(1f)) { - Text(text = "Mines", color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon( - imageVector = Icons.Outlined.Shield, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(SubtitleIconSize), - ) - Text( - text = "Provably Fair · 5x5 Grid", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } - HeaderButton( - icon = Icons.Outlined.IosShare, - contentDescription = "Share last round", - onClick = { context.shareText(SHARE_MESSAGE) }, - ) - HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) - } -} - -@Composable -private fun HeaderButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(HeaderButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = TextHigh, - modifier = Modifier.size(HeaderIconSize), - ) - } -} - @Composable private fun RecentStrip(results: List) { Column( @@ -216,7 +150,7 @@ private fun RecentChip(won: Boolean) { @Composable private fun StatsStrip(state: MinesUiState) { - val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk + val profitColor = if (state.sessionProfitPositive) SemanticOk else SemanticDanger Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -237,26 +171,6 @@ private fun StatsStrip(state: MinesUiState) { } } -@Composable -private fun StatCell( - label: String, - value: String, - valueColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) - } -} - @Composable private fun MinesGrid( state: MinesUiState, @@ -403,7 +317,7 @@ private fun ResultControls( ) } } - ActionButton(label = "New Bet", enabled = true, background = AccentViolet, onClick = onNewBet) + GameActionButton(label = "New Bet", onClick = onNewBet, background = AccentViolet, enabled = true) } @Composable @@ -428,11 +342,11 @@ private fun LiveRoundControls( } Text(text = state.statusLabel, color = TextLow, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) } - ActionButton( + GameActionButton( label = if (state.cashoutLabel.isNotEmpty()) state.cashoutLabel else "Reveal a tile", - enabled = state.canCashout, - background = SemanticOk, onClick = onCashout, + background = SemanticOk, + enabled = state.canCashout, ) } @@ -467,7 +381,7 @@ private fun SetupControls( } } RiskMeter(mines = state.mineCount) - ActionButton(label = "Place Bet", enabled = true, background = AccentViolet, onClick = onPlaceBet) + GameActionButton(label = "Place Bet", onClick = onPlaceBet, background = AccentViolet, enabled = true) } @Composable @@ -539,42 +453,6 @@ private fun RiskMeter(mines: Int) { } } -@Composable -private fun ActionButton( - label: String, - enabled: Boolean, - background: Color, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .background(background.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) - .clickable(enabled = enabled, onClick = onClick) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -/** Opens the system share sheet with [text]; no-op if nothing handles it. */ -private fun Context.shareText(text: String) { - val send = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - runCatching { startActivity(Intent.createChooser(send, null)) } -} - /** Fraction of the risk meter filled for a given mine count. */ private fun riskFraction(mines: Int): Float = when (mines) { @@ -598,34 +476,17 @@ private const val RISK_MID = 0.45f private const val RISK_HIGH = 0.70f private const val RISK_MAX = 1.0f -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") - -// Drops the font's vertical padding so the single W / L glyph sits -// centered inside the small result chips. -private val CenteredGlyph = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) private val RiskOrange = Color(0xFFF97316) -private val ScreenHorizontalPadding = 16.dp private val GridHorizontalPadding = 24.dp -private val HeaderVerticalPadding = 16.dp private val ControlsVerticalPadding = 12.dp -private val HeaderButtonSize = 36.dp -private val HeaderIconSize = 16.dp -private val SubtitleIconSize = 10.dp private val RecentChipSize = 18.dp private val GridGap = 6.dp private val TileDotSize = 12.dp private val ProgressBarHeight = 4.dp private val RiskBarHeight = 2.dp -private val TinyFontSize = 9.sp -private val MetaFontSize = 10.sp -private val TrackedLetterSpacing = 1.2.sp - -private const val CHIP_TINT_ALPHA = 0.15f -private const val CHIP_BORDER_ALPHA = 0.40f private const val PILL_SELECTED_ALPHA = 0.10f private const val TILE_TINT_ALPHA = 0.18f -private const val DISABLED_ALPHA = 0.40f @Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 800) @Composable diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt index d2e1907..fc17c48 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt @@ -30,6 +30,7 @@ data class MinesUiState( val tiles: List = List(GRID) { TileState.Hidden }, val balanceLabel: String = "$0.00", val sessionProfitLabel: String = "+$0.00", + val sessionProfitPositive: Boolean = true, val multiplierLabel: String = "1.00x", val nextGainLabel: String = "-", val cashoutLabel: String = "", diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt index 4c5fd21..bfb0c2d 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt @@ -9,6 +9,9 @@ import com.plainstudio.stackcasino.domain.game.MinesStart import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.formatMultiplier +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +22,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.Locale import javax.inject.Inject /** @@ -62,8 +64,9 @@ class MinesViewModel }.collect { stats -> _state.update { it.copy( - balanceLabel = "$" + money(stats.balance), + balanceLabel = "$" + formatMoney(stats.balance), sessionProfitLabel = signedMoney(stats.profit), + sessionProfitPositive = stats.profit >= 0.0, recentResults = stats.recent, ) } @@ -155,7 +158,7 @@ class MinesViewModel safeRevealed = safe, multiplierLabel = formatMultiplier(multiplier), nextGainLabel = nextGainLabel(active, safe), - cashoutLabel = "Cash Out $" + money(active.stake * multiplier), + cashoutLabel = "Cash Out $" + formatMoney(active.stake * multiplier), statusLabel = "Safe - keep going or cash out", ) } @@ -175,7 +178,7 @@ class MinesViewModel multiplierLabel = formatMultiplier(multiplier), nextGainLabel = "-", cashoutLabel = "", - statusLabel = "Cashed out $" + money(payout), + statusLabel = "Cashed out $" + formatMoney(payout), ) } settle(active, safeRevealed = safe, cashedOut = true) @@ -210,7 +213,7 @@ class MinesViewModel if (safeRevealed >= safeTiles) return "-" val current = MinesMath.multiplier(active.numMines, safeRevealed) val next = MinesMath.multiplier(active.numMines, safeRevealed + 1) - return "+$" + money(active.stake * (next - current)) + return "+$" + formatMoney(active.stake * (next - current)) } private fun MinesUiState.resetToIdle(): MinesUiState = @@ -228,12 +231,6 @@ class MinesViewModel ) } - private fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "x" - - 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)) - private data class Stats( val balance: Double, val recent: List, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt index a9420c1..8084ad9 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt @@ -46,6 +46,7 @@ import com.bumptech.glide.integration.compose.placeholder import com.plainstudio.stackcasino.R import com.plainstudio.stackcasino.domain.news.NewsArticle import com.plainstudio.stackcasino.ui.components.EmptyState +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme import com.plainstudio.stackcasino.ui.theme.SurfaceBase @@ -154,7 +155,7 @@ private fun DetailHeader( onClick = onShare, ) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + HairlineDivider() } @OptIn(ExperimentalGlideComposeApi::class) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt index 7734551..8b24fd8 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt @@ -1,28 +1,17 @@ package com.plainstudio.stackcasino.feature.news -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.text.BasicTextField -import androidx.compose.material.icons.Icons -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.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -32,13 +21,11 @@ import com.plainstudio.stackcasino.ui.components.ErrorState import com.plainstudio.stackcasino.ui.components.ErrorStateDefaults import com.plainstudio.stackcasino.ui.components.FilterChip import com.plainstudio.stackcasino.ui.components.FilterChipRow +import com.plainstudio.stackcasino.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.SearchField import com.plainstudio.stackcasino.ui.components.gridBackground -import com.plainstudio.stackcasino.ui.theme.AccentViolet 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 /** * News screen reproducing the cu-12 mockup @@ -51,7 +38,7 @@ import com.plainstudio.stackcasino.ui.theme.TextLow * * Per the TPO doc the screen mandates Loading / Success / Error * states, Retrofit for the listing, reactive search + filter, Glide - * for image rendering and a parametric Detail route — all wired here. + * for image rendering and a parametric Detail route -- all wired here. */ @Composable fun NewsScreen( @@ -79,7 +66,7 @@ private fun NewsContent( Surface(modifier = Modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize().gridBackground()) { Header() - HorizontalDivider() + HairlineDivider() SearchInput( value = (state as? NewsUiState.Success)?.query.orEmpty(), enabled = state is NewsUiState.Success, @@ -130,48 +117,14 @@ private fun SearchInput( enabled: Boolean, onValueChange: (String) -> Unit, ) { - Row( + SearchField( + value = value, + onValueChange = onValueChange, + placeholder = "Search articles...", + enabled = enabled, 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, - enabled = enabled, - textStyle = TextStyle(color = TextHigh, fontSize = SearchFontSize), - cursorBrush = SolidColor(AccentViolet), - modifier = Modifier.fillMaxWidth(), - ) - if (value.isEmpty()) { - Text( - text = "Search articles...", - color = TextLow, - fontSize = SearchFontSize, - ) - } - } - } + Modifier.padding(horizontal = ScreenHorizontalPadding, vertical = SearchVerticalPadding), + ) } @Composable @@ -218,17 +171,6 @@ private fun NewsErrorBody( } } -@Composable -internal fun HorizontalDivider() { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(SurfaceOutline), - ) -} - // --------------------------------------------------------------------------- // Tokens shared across the news feature files. // --------------------------------------------------------------------------- @@ -242,8 +184,4 @@ internal val TrackedLetterSpacing = 1.2.sp private val HeaderTopPadding = 24.dp private val HeaderBottomPadding = 16.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 SourceChipsVerticalPadding = 8.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/notifications/NotificationsSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/notifications/NotificationsSheet.kt index 5405e94..737e233 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/notifications/NotificationsSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/notifications/NotificationsSheet.kt @@ -12,7 +12,6 @@ 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.verticalScroll import androidx.compose.material.icons.Icons @@ -41,6 +40,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.plainstudio.stackcasino.model.Notification import com.plainstudio.stackcasino.model.NotificationCategory +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SemanticWarn @@ -78,14 +78,14 @@ internal fun NotificationsSheet( ) { Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { Header(unread = unread, total = notifications.size, onMarkAllRead = viewModel::markAllRead) - Divider() + HairlineDivider() notifications.forEach { notification -> NotificationRow( notification = notification, now = now, onClick = { viewModel.markRead(notification.id) }, ) - Divider() + HairlineDivider() } Text( text = "End of inbox", @@ -219,11 +219,6 @@ private fun IconBadge( } } -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) -} - /** Draws the left accent rail on unread rows (mockup's border-l-[3px]). */ private fun Modifier.accentRail( visible: Boolean, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt index fa39740..89bb83b 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.TextHigh @@ -53,14 +55,14 @@ internal fun ProfileAccountSection( letterSpacing = TrackedLetterSpacing, ) Spacer(modifier = Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth().profileCardSurface()) { + Column(modifier = Modifier.fillMaxWidth().cardSurface()) { AccountRow( icon = Icons.Outlined.Shield, title = "Verify Identity", subtitle = "Upload DNI · unlock withdrawals > $100", onClick = onOpenKyc, ) - Divider() + HairlineDivider() AccountRow( icon = Icons.Outlined.AccountBalanceWallet, title = "House Wallet", @@ -68,14 +70,14 @@ internal fun ProfileAccountSection( onClick = onOpenHouseWallet, badge = "Operator", ) - Divider() + HairlineDivider() AccountRow( icon = Icons.Outlined.Notifications, title = "Notifications", subtitle = "Push · deposit, withdrawal, security", onClick = {}, ) - Divider() + HairlineDivider() SignOutRow(onClick = onSignOut) } Spacer(modifier = Modifier.height(12.dp)) 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 24c1dd2..78afe34 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 @@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.displayName +import com.plainstudio.stackcasino.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger @@ -62,7 +65,7 @@ internal fun ProfileGameActivity(rows: List) { ) } Spacer(modifier = Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth().profileCardSurface()) { + Column(modifier = Modifier.fillMaxWidth().cardSurface()) { if (rows.isEmpty()) { Text( text = "No game activity yet", @@ -72,7 +75,7 @@ internal fun ProfileGameActivity(rows: List) { ) } else { rows.forEachIndexed { index, row -> - if (index > 0) Divider() + if (index > 0) HairlineDivider() GameActivityRowItem(row = row) } } @@ -96,7 +99,7 @@ private fun GameActivityRowItem(row: GameActivityRow) { GameGlyph(game = row.game) Column { Text( - text = gameLabel(row.game), + text = row.game.displayName, color = TextHigh, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, @@ -169,15 +172,6 @@ private fun ActivityAccent.color(): Color = ActivityAccent.Danger -> SemanticDanger } -private fun gameLabel(game: GameKey): String = - when (game) { - GameKey.Roulette -> "Roulette" - GameKey.Blackjack -> "Blackjack" - GameKey.Crash -> "Crash" - GameKey.Mines -> "Mines" - GameKey.Coinflip -> "Coinflip" - } - private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val SectionTitleFontSize = 11.sp private val GlyphBoxSize = 32.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt index f2c06d8..b77f577 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.ui.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SemanticWarn @@ -40,7 +42,7 @@ internal fun ProfileIdentityCard( memberSinceLabel: String, isVerified: Boolean, ) { - Column(modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { AvatarTile(initials = identity.initials) Column(modifier = Modifier.weight(1f)) { @@ -62,7 +64,7 @@ internal fun ProfileIdentityCard( } } Spacer(modifier = Modifier.height(16.dp)) - Divider() + HairlineDivider() Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt index a509b43..5b74950 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.plainstudio.stackcasino.ui.components.Skeleton +import com.plainstudio.stackcasino.ui.components.cardSurface /** * Shimmer placeholder shown while the identity stream resolves. Mirrors @@ -31,7 +32,7 @@ internal fun ProfileLoadingBody(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.spacedBy(SectionGap), ) { Row( - modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Skeleton(modifier = Modifier.size(64.dp)) @@ -42,7 +43,7 @@ internal fun ProfileLoadingBody(modifier: Modifier = Modifier) { } } Column( - modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Skeleton(modifier = Modifier.fillMaxWidth(PNL_LABEL_FRACTION).height(8.dp)) @@ -50,7 +51,7 @@ internal fun ProfileLoadingBody(modifier: Modifier = Modifier) { Skeleton(modifier = Modifier.fillMaxWidth().height(6.dp)) } Column( - modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { repeat(ACTIVITY_ROW_COUNT) { diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt index b34fcb6..996e360 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SurfaceBase @@ -33,7 +34,7 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium */ @Composable internal fun ProfilePnlCard(pnl: ProfilePnl) { - Column(modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -78,8 +79,14 @@ private fun ProportionBar(wonFraction: Float) { .background(SurfaceBase) .border(width = 1.dp, color = SurfaceOutline), ) { - Box(modifier = Modifier.fillMaxHeight().weight(wonFraction).background(SemanticOk)) - Box(modifier = Modifier.fillMaxHeight().weight(1f - wonFraction).background(SemanticDanger)) + // weight() rejects a zero/negative value, so each segment is only laid + // out when it has a non-zero share (wonFraction 0f -> all loss, 1f -> all won). + if (wonFraction > 0f) { + Box(modifier = Modifier.fillMaxHeight().weight(wonFraction).background(SemanticOk)) + } + if (wonFraction < 1f) { + Box(modifier = Modifier.fillMaxHeight().weight(1f - wonFraction).background(SemanticDanger)) + } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt index 2dcf2e7..ba0e657 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.plainstudio.stackcasino.ui.components.ErrorState +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme @@ -96,7 +97,7 @@ internal fun ProfileScreenContent( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize().gridBackground()) { ProfileHeader(onOpenSettings = onOpenSettings) - Divider() + HairlineDivider() when (state) { is ProfileUiState.Success -> ProfileSuccessBody( @@ -222,18 +223,6 @@ private fun ProfileErrorBody( // Shared building blocks // --------------------------------------------------------------------------- -/** - * Bordered graphite surface every profile card sits on (identity, P&L, - * activity list, account list). Callers add their own content padding. - */ -internal fun Modifier.profileCardSurface(): Modifier = - background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) - -@Composable -internal fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) -} - internal val ScreenHorizontalPadding = 16.dp internal val SectionGap = 20.dp internal val CardPadding = 16.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt index a6c24fb..2ba06b2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.plainstudio.stackcasino.model.UserSettings +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.LocalToastController import com.plainstudio.stackcasino.ui.components.ToastData import com.plainstudio.stackcasino.ui.components.ToastType @@ -87,18 +88,18 @@ internal fun ProfileSettingsSheet( fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = RowHorizontalPadding, end = RowHorizontalPadding, bottom = 16.dp), ) - Divider() + HairlineDivider() PushNotificationsSection(settings = settings, viewModel = viewModel) - Divider() + HairlineDivider() ToggleRow( title = "Hide Balance on Launch", subtitle = "Start with balance hidden", isOn = settings.hideBalanceOnLaunch, onToggle = viewModel::setHideBalanceOnLaunch, ) - Divider() + HairlineDivider() ClearCacheRow(cacheSizeBytes = cacheSize, onClear = viewModel::clearCache) - Divider() + HairlineDivider() AboutBlock() } } @@ -268,7 +269,7 @@ private fun ToggleVisual(isOn: Boolean) { /** Formats a byte count as the compact size shown in the cache row / toast. */ private fun formatBytes(bytes: Long?): String = when { - bytes == null -> "—" + bytes == null -> "-" bytes >= MEGABYTE -> String.format(Locale.US, "%.1f MB", bytes / MEGABYTE.toDouble()) else -> String.format(Locale.US, "%.0f KB", bytes / KILOBYTE.toDouble()) } 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 c669ec1..56dc962 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 @@ -8,7 +8,9 @@ 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.formatMoney import com.plainstudio.stackcasino.util.initialsOf +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -101,8 +103,8 @@ class ProfileViewModel isNetNegative = net < 0, betsLabel = "$bets bets", winRateLabel = String.format(Locale.US, "%.1f%% win rate", winRate), - wonLabel = "Won $" + money(won), - wageredLabel = "Wagered $" + money(wagered), + wonLabel = "Won $" + formatMoney(won), + wageredLabel = "Wagered $" + formatMoney(wagered), wonFraction = wonFraction, ) } @@ -123,7 +125,7 @@ class ProfileViewModel return stats.map { stat -> GameActivityRow( game = stat.game, - betsWageredLabel = "${stat.bets} bets · $" + money(stat.wagered) + " wagered", + betsWageredLabel = "${stat.bets} bets · $" + formatMoney(stat.wagered) + " wagered", pnlLabel = signedMoney(stat.pnl), isPositive = stat.pnl >= 0, fillFraction = (stat.wagered / maxWagered).toFloat().coerceIn(0f, 1f), @@ -141,10 +143,6 @@ class ProfileViewModel 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, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteRulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteRulesSheet.kt index 3f8090d..429066f 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteRulesSheet.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteRulesSheet.kt @@ -1,105 +1,20 @@ package com.plainstudio.stackcasino.feature.roulette -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.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 +import com.plainstudio.stackcasino.ui.components.RulesSheet /** - * Roulette rules sheet reached from the header, styled after the - * mockup's legal sheets: a tracked label, a bold title and a meta line - * over the numbered rule sections. + * Roulette rules sheet reached from the header. Thin wrapper over the + * shared [RulesSheet]; only the title, meta line and rule data differ. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun RouletteRulesSheet(onDismiss: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = SurfaceRaised, - contentColor = TextHigh, - ) { - Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), - ) { - Text( - text = "How to Play", - color = AccentViolet, - fontSize = LabelFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "Roulette Rules", color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "European · Single Zero · RTP 97.3%", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TabularNums, - ) - } - Divider() - Column( - modifier = Modifier.fillMaxWidth().padding(SheetPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - RULES.forEach { (label, body) -> RuleSection(label = label, body = body) } - Spacer(modifier = Modifier.height(4.dp)) - Divider() - Text( - text = "End of rules · Must be 18+ to play", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), - ) - } - } - } -} - -@Composable -private fun RuleSection( - label: String, - body: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) - } -} - -@Composable -private fun Divider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + RulesSheet( + title = "Roulette Rules", + meta = "European · Single Zero · RTP 97.3%", + rules = RULES, + onDismiss = onDismiss, + ) } private val RULES = @@ -122,9 +37,3 @@ private val RULES = "The winning pocket is derived from HMAC-SHA256(server_seed, client_seed:nonce) mod 37. European " + "(single zero) gives a 2.7% house edge.", ) - -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") -private val SheetPadding = 20.dp -private val LabelFontSize = 10.sp -private val MetaFontSize = 9.sp -private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt index 9178322..6d9d1fa 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt @@ -1,7 +1,5 @@ package com.plainstudio.stackcasino.feature.roulette -import android.content.Context -import android.content.Intent import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween @@ -13,7 +11,6 @@ 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 @@ -22,12 +19,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth -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.MenuBook -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 @@ -43,26 +34,32 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -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.domain.game.RouletteBet import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.ui.components.GameActionButton +import com.plainstudio.stackcasino.ui.components.GameHeader +import com.plainstudio.stackcasino.ui.components.OutcomeTone +import com.plainstudio.stackcasino.ui.components.StatCell import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.MetaFontSize +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding 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.TabularNums import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing /** * Roulette game screen (mockup/js/screens/roulette.js). @@ -89,7 +86,13 @@ fun RouletteScreen( Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { - RouletteHeader(onBack = onBack, onOpenRules = { showRules = true }) + GameHeader( + title = "Roulette", + subtitle = "Provably Fair · European · Single Zero", + shareMessage = SHARE_MESSAGE, + onBack = onBack, + onOpenRules = { showRules = true }, + ) RecentStrip(pockets = state.recentPockets) StatsStrip(state = state) Reel(state = state, onReelSettled = onReelSettled) @@ -109,76 +112,6 @@ fun RouletteScreen( } } -@Composable -private fun RouletteHeader( - onBack: () -> Unit, - onOpenRules: () -> Unit, -) { - val context = LocalContext.current - Row( - modifier = - Modifier.fillMaxWidth().padding( - horizontal = ScreenHorizontalPadding, - vertical = HeaderVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - HeaderButton( - icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - Column(modifier = Modifier.weight(1f)) { - Text(text = "Roulette", color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon( - imageVector = Icons.Outlined.Shield, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(SubtitleIconSize), - ) - Text( - text = "Provably Fair · European · Single Zero", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } - HeaderButton( - icon = Icons.Outlined.IosShare, - contentDescription = "Share last round", - onClick = { context.shareText(SHARE_MESSAGE) }, - ) - HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) - } -} - -@Composable -private fun HeaderButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(HeaderButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = TextHigh, - modifier = Modifier.size(HeaderIconSize), - ) - } -} - @Composable private fun RecentStrip(pockets: List) { Column( @@ -213,7 +146,7 @@ private fun HistoryChip(number: Int) { @Composable private fun StatsStrip(state: RouletteUiState) { - val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk + val profitColor = if (state.sessionProfitPositive) SemanticOk else SemanticDanger Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -234,26 +167,6 @@ private fun StatsStrip(state: RouletteUiState) { } } -@Composable -private fun StatCell( - label: String, - value: String, - valueColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) - } -} - /** Horizontal wheel reel that animates to the winning pocket on a spin. */ @Composable private fun Reel( @@ -527,15 +440,27 @@ private fun RouletteControls( RoulettePhase.Result -> { Text( text = state.resultLabel, - color = if (state.resultLabel.contains("won")) SemanticOk else SemanticDanger, + color = if (state.resultTone == OutcomeTone.Win) SemanticOk else SemanticDanger, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = TrackedLetterSpacing, ) - ActionButton(label = "New Bet", background = AccentViolet, enabled = true, onClick = onNewBet) + GameActionButton( + label = "New Bet", + onClick = onNewBet, + background = AccentViolet, + enabled = true, + verticalPadding = 10.dp, + ) } RoulettePhase.Spinning -> - ActionButton(label = "Spinning...", background = AccentViolet, enabled = false, onClick = {}) + GameActionButton( + label = "Spinning...", + onClick = {}, + background = AccentViolet, + enabled = false, + verticalPadding = 10.dp, + ) RoulettePhase.Idle -> { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { CHIP_VALUES.forEach { value -> @@ -549,12 +474,13 @@ private fun RouletteControls( } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { ClearButton(onClick = onClear, modifier = Modifier.weight(1f)) - ActionButton( + GameActionButton( label = "SPIN", + onClick = onSpin, background = AccentViolet, enabled = state.canSpin, - onClick = onSpin, modifier = Modifier.weight(SPIN_BUTTON_WEIGHT), + verticalPadding = 10.dp, ) } } @@ -615,43 +541,6 @@ private fun ClearButton( } } -@Composable -private fun ActionButton( - label: String, - background: Color, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = - modifier - .fillMaxWidth() - .background(background.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) - .clickable(enabled = enabled, onClick = onClick) - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -/** Opens the system share sheet with [text]; no-op if nothing handles it. */ -private fun Context.shareText(text: String) { - val send = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - runCatching { startActivity(Intent.createChooser(send, null)) } -} - // Solid "black" pocket fill from the mockup (#1a1a22), distinct from the surface tokens. private val BLACK_POCKET_FILL = Color(0xFF1A1A22) private const val RED_CELL_FILL_ALPHA = 0.30f @@ -777,14 +666,7 @@ private const val SPIN_MS = 4200 private const val REEL_LAPS = 6 private const val REEL_TARGET_LAP = 4 -private val TabularNums = TextStyle(fontFeatureSettings = "tnum") - -private val ScreenHorizontalPadding = 16.dp -private val HeaderVerticalPadding = 16.dp private val ControlsVerticalPadding = 8.dp -private val HeaderButtonSize = 36.dp -private val HeaderIconSize = 16.dp -private val SubtitleIconSize = 10.dp private val HistoryChipSize = 20.dp private val ReelHeight = 42.dp private val ReelTileWidth = 44.dp @@ -794,13 +676,9 @@ private val ZeroCellHeight = 24.dp private val ColumnBetHeight = 24.dp private val ChipBadgeSize = 14.dp -private val TinyFontSize = 9.sp -private val MetaFontSize = 10.sp -private val TrackedLetterSpacing = 1.2.sp private const val VERTICAL_LABEL_ROTATION = -90f private const val CHIP_SELECTED_ALPHA = 0.10f -private const val DISABLED_ALPHA = 0.40f private const val NUMBER_FILL_ALPHA = 0.25f private const val NUMBER_BORDER_ALPHA = 0.40f private const val REEL_POINTER_ALPHA = 0.90f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt index 849fa6f..6d0b715 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt @@ -1,6 +1,7 @@ package com.plainstudio.stackcasino.feature.roulette import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.ui.components.OutcomeTone /** Lifecycle of a roulette round. */ enum class RoulettePhase { @@ -25,10 +26,12 @@ data class RouletteUiState( val bets: Map = emptyMap(), val balanceLabel: String = "$0.00", val sessionProfitLabel: String = "+$0.00", + val sessionProfitPositive: Boolean = true, val recentPockets: List = emptyList(), val winningPocket: Int? = null, val onTableLabel: String = "$0.00", val resultLabel: String = "", + val resultTone: OutcomeTone = OutcomeTone.Loss, ) { val totalStaked: Int get() = bets.values.sum() diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt index ca25883..2e497e5 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt @@ -8,6 +8,9 @@ import com.plainstudio.stackcasino.domain.game.RouletteSpot import com.plainstudio.stackcasino.domain.game.RouletteStart import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.ui.components.OutcomeTone +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +21,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.Locale import javax.inject.Inject /** @@ -61,8 +63,9 @@ class RouletteViewModel }.collect { stats -> _state.update { it.copy( - balanceLabel = "$" + money(stats.balance), + balanceLabel = "$" + formatMoney(stats.balance), sessionProfitLabel = signedMoney(stats.profit), + sessionProfitPositive = stats.profit >= 0.0, recentPockets = stats.recent, ) } @@ -78,7 +81,7 @@ class RouletteViewModel if (!_state.value.isIdle) return _state.update { val updated = it.bets + (spot to (it.bets[spot] ?: 0) + it.chip) - it.copy(bets = updated, onTableLabel = "$" + money(updated.values.sum().toDouble())) + it.copy(bets = updated, onTableLabel = "$" + formatMoney(updated.values.sum().toDouble())) } } @@ -111,7 +114,13 @@ class RouletteViewModel val settled = spin ?: return if (_state.value.phase != RoulettePhase.Spinning) return spin = null - _state.update { it.copy(phase = RoulettePhase.Result, resultLabel = settled.resultLabel()) } + _state.update { + it.copy( + phase = RoulettePhase.Result, + resultLabel = settled.resultLabel(), + resultTone = settled.resultTone(), + ) + } viewModelScope.launch { gameRepository.settleRouletteSpin(settled) } } @@ -131,12 +140,11 @@ class RouletteViewModel private fun RouletteSpin.resultLabel(): String { val net = totalReturn - totalStake - return if (net > 0.0) "Landed $pocket - won $" + money(totalReturn) else "Landed $pocket - no win" + return if (net > 0.0) "Landed $pocket - won $" + formatMoney(totalReturn) else "Landed $pocket - no win" } - 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)) + private fun RouletteSpin.resultTone(): OutcomeTone = + if (totalReturn - totalStake > 0.0) OutcomeTone.Win else OutcomeTone.Loss private data class Stats( val balance: Double, 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 index 2951741..49c8454 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt @@ -6,11 +6,9 @@ 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 @@ -35,6 +33,8 @@ 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.components.HairlineDivider +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticOk import com.plainstudio.stackcasino.ui.theme.SurfaceBase @@ -64,14 +64,14 @@ internal fun RoundDetailProvablyFair(provablyFair: ProvablyFair) { } } - Column(modifier = Modifier.fillMaxWidth().roundDetailCardSurface()) { + Column(modifier = Modifier.fillMaxWidth().cardSurface()) { ProvablyFairHeader(expanded = expanded, onToggle = { expanded = !expanded }) AnimatedVisibility(visible = expanded) { Column( modifier = Modifier.padding(horizontal = CardPadding, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - HorizontalRule() + HairlineDivider() listOf( provablyFair.serverSeed, provablyFair.clientSeed, @@ -200,11 +200,6 @@ private fun SeedRow( } } -@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 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 index 54e6aa4..294dda1 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt @@ -38,7 +38,9 @@ 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.HairlineDivider import com.plainstudio.stackcasino.ui.components.StackCard +import com.plainstudio.stackcasino.ui.components.cardSurface import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger @@ -271,7 +273,7 @@ private fun ResultBanner(data: RoundDetailData) { 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)) { + Column(modifier = Modifier.weight(1f).cardSurface().padding(12.dp)) { Text( text = stat.label, color = TextMedium, @@ -378,7 +380,7 @@ private fun CrashMarker( @Composable private fun AuditRow(auditRef: String) { Row( - modifier = Modifier.fillMaxWidth().roundDetailCardSurface().padding(12.dp), + modifier = Modifier.fillMaxWidth().cardSurface().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -413,15 +415,12 @@ private fun AuditRow(auditRef: String) { // 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)) { + Column(modifier = Modifier.fillMaxWidth().cardSurface().padding(CardPadding)) { Text( text = title, color = TextMedium, @@ -433,11 +432,6 @@ internal fun DetailCard( } } -@Composable -private fun HairlineDivider() { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) -} - private fun StatTone.color(): Color = when (this) { StatTone.Neutral -> TextHigh 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 index 00acd18..412a7b0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailTimeline.kt @@ -20,10 +20,10 @@ 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.components.VerticalHairline 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 @@ -54,14 +54,7 @@ private fun TimelineRow( // 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), - ) + VerticalHairline(modifier = Modifier.align(Alignment.TopCenter)) } Box( modifier = diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt index 04a6cf1..7c19cfd 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt @@ -8,14 +8,16 @@ 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.model.displayName import com.plainstudio.stackcasino.navigation.Route +import com.plainstudio.stackcasino.util.TimestampFormat +import com.plainstudio.stackcasino.util.formatMoney +import com.plainstudio.stackcasino.util.signedMoney import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale import javax.inject.Inject @@ -57,11 +59,11 @@ class RoundDetailViewModel val short = shortId() return RoundDetailData( game = game, - gameName = game.displayName(), - headerSubtitle = "${game.displayName()} · #$short · $currency", + gameName = game.displayName, + headerSubtitle = "${game.displayName} · #$short · $currency", outcome = outcome, netProfitLabel = signedMoney(netProfit), - summaryLabel = "Bet $${money(stake)} · Payout $${money(payout)}", + summaryLabel = "Bet $${formatMoney(stake)} · Payout $${formatMoney(payout)}", stats = stats(won), crashBar = crashBar(won), safeByLabel = null, @@ -83,33 +85,33 @@ class RoundDetailViewModel return when (game) { GameKey.Coinflip -> listOf( - RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Bet", "$${formatMoney(stake)}", StatTone.Neutral), RoundStat("Prediction", pick, StatTone.Accent), RoundStat("Result", CoinSide.fromEngineResult(resultRaw).name, resultTone), ) GameKey.Crash -> listOf( - RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Bet", "$${formatMoney(stake)}", StatTone.Neutral), RoundStat("Cashed Out", if (won) "${multiplierText()}x" else "-", resultTone), RoundStat("Crash Point", "${crashText()}x", StatTone.Danger), ) GameKey.Roulette -> listOf( - RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Bet", "$${formatMoney(stake)}", StatTone.Neutral), RoundStat("Winning Pocket", resultRaw, StatTone.Accent), RoundStat("Multiplier", "${multiplierText()}x", resultTone), ) GameKey.Blackjack -> { val totals = resultRaw.split("/") listOf( - RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Bet", "$${formatMoney(stake)}", StatTone.Neutral), RoundStat("Your Hand", totals.getOrElse(0) { "-" }, StatTone.Accent), RoundStat("Dealer", totals.getOrElse(1) { "-" }, resultTone), ) } else -> listOf( - RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Bet", "$${formatMoney(stake)}", StatTone.Neutral), RoundStat("Setup", pick, StatTone.Accent), RoundStat("Multiplier", "${multiplierText()}x", resultTone), ) @@ -117,13 +119,13 @@ class RoundDetailViewModel } private fun GameRound.buildTimeline(won: Boolean): List { - val at = TIME_FORMAT.format(Date(timestamp)) + val at = TimestampFormat.clock(timestamp) return listOf( TimelineEvent("Round started", at, TimelineTone.Neutral), - TimelineEvent("Bet placed · $${money(stake)}", at, TimelineTone.Neutral), + TimelineEvent("Bet placed · $${formatMoney(stake)}", at, TimelineTone.Neutral), outcomeEvent(won, at), if (won) { - TimelineEvent("Payout credited · $${money(payout)}", at, TimelineTone.Ok) + TimelineEvent("Payout credited · $${formatMoney(payout)}", at, TimelineTone.Ok) } else { TimelineEvent("No payout · stake lost", at, TimelineTone.Danger) }, @@ -184,22 +186,8 @@ class RoundDetailViewModel private fun GameRound.shortId(): String = id.filter { it.isLetterOrDigit() }.take(SHORT_ID_LEN) - private fun GameKey.displayName(): String = - when (this) { - GameKey.Roulette -> "Roulette" - GameKey.Blackjack -> "Blackjack" - GameKey.Crash -> "Crash" - GameKey.Mines -> "Mines" - GameKey.Coinflip -> "Coinflip" - } - - 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)) - private companion object { const val SHORT_ID_LEN = 6 - val TIME_FORMAT = SimpleDateFormat("h:mm:ss a", Locale.US) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt index ed47d6e..f5966f3 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt @@ -1,9 +1,7 @@ package com.plainstudio.stackcasino.feature.wallet -import androidx.compose.foundation.background 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.Spacer @@ -32,11 +30,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.navigation.Route import com.plainstudio.stackcasino.ui.components.CurrencyDropdown +import com.plainstudio.stackcasino.ui.components.HairlineDivider import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.AccentViolet 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.TextHigh import com.plainstudio.stackcasino.ui.theme.TextMedium @@ -76,9 +74,9 @@ fun WalletScreen( .verticalScroll(rememberScrollState()), ) { WalletHeader() - HorizontalDivider() + HairlineDivider() BalanceBlock(data = data) - HorizontalDivider() + HairlineDivider() WalletTabStrip(selected = selectedTab, onSelect = { selectedTab = it }) when (selectedTab) { WalletTab.Deposit -> WalletDepositTab(address = data.depositAddress) @@ -196,7 +194,7 @@ private fun WalletTabStrip( ) } } - HorizontalDivider() + HairlineDivider() } @Composable @@ -232,17 +230,6 @@ private fun WalletTabButton( ) } -@Composable -internal fun HorizontalDivider() { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(SurfaceOutline), - ) -} - // --------------------------------------------------------------------------- // Tokens shared with the tab files via the `internal` modifier. // --------------------------------------------------------------------------- diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletViewModel.kt index a808564..89bf298 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletViewModel.kt @@ -8,6 +8,8 @@ import com.plainstudio.stackcasino.domain.wallet.WalletRepository import com.plainstudio.stackcasino.domain.wallet.WalletTxStatus import com.plainstudio.stackcasino.domain.wallet.WalletTxType import com.plainstudio.stackcasino.domain.wallet.WithdrawResult +import com.plainstudio.stackcasino.util.TimestampFormat +import com.plainstudio.stackcasino.util.formatMoney import com.plainstudio.stackcasino.util.truncateMiddle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -19,8 +21,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale import javax.inject.Inject @@ -120,8 +120,8 @@ class WalletViewModel WalletTransaction( type = if (type == WalletTxType.Deposit) TransactionType.Deposit else TransactionType.Withdraw, status = status.toUiStatus(), - timestampLabel = TIMESTAMP_FORMAT.format(Date(timestamp)), - shortHash = hash ?: destinationAddress?.let { truncateMiddle(it, keepHead = 6, keepTail = 4) } ?: "—", + timestampLabel = TimestampFormat.ledger(timestamp), + shortHash = hash ?: destinationAddress?.let { truncateMiddle(it, keepHead = 6, keepTail = 4) } ?: "-", amountLabel = (if (type == WalletTxType.Deposit) "+$" else "-$") + formatMoney(amount), currencyCode = currency, ) @@ -133,15 +133,12 @@ class WalletViewModel WalletTxStatus.Failed -> TransactionStatus.Failed } - private fun formatMoney(value: Double): String = String.format(Locale.US, "%,.2f", value) - private companion object { const val STOP_TIMEOUT_MILLIS = 5_000L const val KYC_THRESHOLD = 100.0 const val IS_KYC_VERIFIED = false const val NETWORK_LABEL = "Polygon" val ADDRESS_REGEX = Regex("^0x[0-9a-fA-F]{40}$") - val TIMESTAMP_FORMAT = SimpleDateFormat("M/d/yyyy · h:mm a", Locale.US) val EMPTY_DATA = WalletData( availableLabel = "$0.00", diff --git a/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt b/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt index 619768e..069da1a 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/model/Game.kt @@ -14,6 +14,20 @@ enum class GameKey { Coinflip, } +/** + * Human-facing label for a game, shared by the lobby, history and round + * detail so the `GameKey -> display name` mapping lives in one place. + */ +val GameKey.displayName: String + get() = + when (this) { + GameKey.Roulette -> "Roulette" + GameKey.Blackjack -> "Blackjack" + GameKey.Crash -> "Crash" + GameKey.Mines -> "Mines" + GameKey.Coinflip -> "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 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 d84657c..995c3ff 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -281,6 +281,8 @@ private fun NavGraphBuilder.addLobbyRoute(navController: NavHostController) { onOpenWallet = { tab -> navController.navigate(Route.Wallet.build(tab)) { launchSingleTop = true } }, + // Intentional no-ops: the lobby has no error state to recover from yet, so retry and + // use-cache stay unwired until one is introduced. onRetry = {}, onUseCache = {}, hideBalanceOnLaunch = settings.hideBalanceOnLaunch, diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt index 69172c4..f45b8d4 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt @@ -37,7 +37,7 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium * label: tracked 10sp, txt-mid color * amount: 36sp bold, tabular-nums for stable layout * eye: 18dp outlined icon, txt-lo (hover violet) - * hidden: amount replaced by 6 dots (••••••) + * hidden: amount replaced by a 6-dot mask * * The eye toggle is optional. When [onToggleVisibility] is null the * eye control is hidden and the pill always shows [amount] (used by diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/CardSurface.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/CardSurface.kt new file mode 100644 index 0000000..e48f05d --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/CardSurface.kt @@ -0,0 +1,23 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised + +/** + * Mockup `border border-line bg-surface` card recipe: a raised fill with a + * one-pixel outline. Replaces the per-feature `lobbyCardSurface` / + * `profileCardSurface` / `kycCardSurface` / `roundDetailCardSurface` / + * `hwCardSurface` copies that all expanded to the same two modifiers. + * + * @param fill surface color behind the content. + * @param stroke outline color. + */ +fun Modifier.cardSurface( + fill: Color = SurfaceRaised, + stroke: Color = SurfaceOutline, +): Modifier = background(fill).border(width = 1.dp, color = stroke) diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt index 25ff84e..d1707eb 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt @@ -2,11 +2,9 @@ package com.plainstudio.stackcasino.ui.components 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.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -23,7 +21,6 @@ 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.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.SurfaceRaised @@ -96,18 +93,7 @@ fun EmptyState( ) } if (actionLabel != null && onAction != null) { - Text( - text = actionLabel.uppercase(), - color = AccentViolet, - fontSize = ActionFontSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = ActionLetterSpacing, - modifier = - Modifier - .border(width = 1.dp, color = AccentViolet) - .clickable(onClick = onAction) - .padding(ActionPadding), - ) + StackOutlineButton(label = actionLabel, onClick = onAction) } } } @@ -133,9 +119,6 @@ private val MessageMaxWidth = 280.dp private val TitleFontSize = 16.sp private val MessageFontSize = 12.sp private val MessageLineHeight = 18.sp -private val ActionFontSize = 11.sp -private val ActionLetterSpacing = 1.2.sp -private val ActionPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp) @Preview(showBackground = true, backgroundColor = 0xFF0B0B12) @Composable diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt index 8dac1fb..6ed9c55 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt @@ -128,6 +128,10 @@ private fun ActionRow( } } +// Not folded into the shared StackOutlineButton: the error state needs a +// filled (violet bg, white text) primary variant plus a transparent +// SurfaceOutline-bordered secondary, neither of which matches the shared +// button's fixed violet-outline styling. @Composable private fun ErrorActionButton( label: String, @@ -159,7 +163,7 @@ private fun ErrorActionButton( @Composable private fun FooterRow(text: String) { Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + HairlineDivider() Spacer(modifier = Modifier.height(FooterGap)) Text( text = text.uppercase(), 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 6bd171a..db6ccd8 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 @@ -58,39 +58,30 @@ fun FilterChipRow( scrollable: Boolean = false, wrap: Boolean = false, ) { - 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) }) - } - } + val chipContent: @Composable () -> Unit = { + 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) }) - } - } + } + // Precedence matches the parameter order: scrollable wins over wrap. Only the + // FlowRow-vs-Row choice (the wrap branch) differs; the row modifier is computed + // once for both the scrollable and default lanes. + if (wrap && !scrollable) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalArrangement = Arrangement.spacedBy(ChipGap), + ) { + chipContent() } - 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) }) - } - } + } else { + val rowModifier = if (scrollable) modifier.horizontalScroll(rememberScrollState()) else modifier + Row( + modifier = rowModifier, + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalAlignment = Alignment.CenterVertically, + ) { + chipContent() } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameActionButton.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameActionButton.kt new file mode 100644 index 0000000..3c4b16b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameActionButton.kt @@ -0,0 +1,58 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.font.FontWeight +import androidx.compose.ui.unit.Dp +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.DISABLED_ALPHA +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing + +/** + * Full-width filled primary action button shared by every game screen + * (formerly FlipButton / ActionButton / PrimaryButton). When [enabled] is + * false the [background] fades to [DISABLED_ALPHA] and taps are ignored. + * + * @param label uppercase action text, e.g. "FLIP". + * @param onClick invoked on tap when [enabled]. + * @param background fill color; defaults to the accent. + * @param enabled whether the button is interactive. + * @param verticalPadding inner vertical padding (roulette uses a tighter row). + */ +@Composable +fun GameActionButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + background: Color = AccentViolet, + enabled: Boolean = true, + verticalPadding: Dp = 12.dp, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .background(background.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = verticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameHeader.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameHeader.kt new file mode 100644 index 0000000..64267ea --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/GameHeader.kt @@ -0,0 +1,127 @@ +package com.plainstudio.stackcasino.ui.components + +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.padding +import androidx.compose.foundation.layout.size +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.MenuBook +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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.HeaderButtonSize +import com.plainstudio.stackcasino.ui.theme.HeaderIconSize +import com.plainstudio.stackcasino.ui.theme.HeaderVerticalPadding +import com.plainstudio.stackcasino.ui.theme.ScreenHorizontalPadding +import com.plainstudio.stackcasino.ui.theme.SubtitleIconSize +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.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing +import com.plainstudio.stackcasino.util.shareText + +/** + * Shared game-screen header: a back chip, the game [title] with a + * Shield-prefixed provably-fair [subtitle], and Share / Rules chips. + * + * Identical across all five game screens save for the copy, so each + * screen reduces to a single call. The Share chip opens the system + * share sheet with [shareMessage]. + * + * @param title bold game name, e.g. "Coinflip". + * @param subtitle tracked provably-fair line under the title. + * @param shareMessage plain text shared by the Share chip. + * @param onBack invoked by the back chip. + * @param onOpenRules invoked by the Rules chip. + */ +@Composable +fun GameHeader( + title: String, + subtitle: String, + shareMessage: String, + onBack: () -> Unit, + onOpenRules: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = HeaderVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + HeaderButton( + icon = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Back", + onClick = onBack, + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, color = TextHigh, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(SubtitleIconSize), + ) + Text( + text = subtitle, + color = TextLow, + fontSize = TinyFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + HeaderButton( + icon = Icons.Outlined.IosShare, + contentDescription = "Share last round", + onClick = { context.shareText(shareMessage) }, + ) + HeaderButton(icon = Icons.Outlined.MenuBook, contentDescription = "Rules", onClick = onOpenRules) + } +} + +@Composable +private fun HeaderButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(HeaderButtonSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = TextHigh, + modifier = Modifier.size(HeaderIconSize), + ) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/HairlineDivider.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/HairlineDivider.kt new file mode 100644 index 0000000..515e926 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/HairlineDivider.kt @@ -0,0 +1,31 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline + +/** + * One-pixel horizontal rule in the outline color, matching the mockup's + * `border-line` separators. Replaces the per-feature `Divider` / + * `HorizontalDivider` / `HairlineDivider` / `HorizontalRule` copies. + */ +@Composable +fun HairlineDivider(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +/** + * Vertical counterpart of [HairlineDivider]: a one-pixel column in the + * outline color, used to separate inline cells. + */ +@Composable +fun VerticalHairline(modifier: Modifier = Modifier) { + Box(modifier = modifier.width(1.dp).fillMaxHeight().background(SurfaceOutline)) +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/OutcomeTone.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/OutcomeTone.kt new file mode 100644 index 0000000..0fe7c5b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/OutcomeTone.kt @@ -0,0 +1,11 @@ +package com.plainstudio.stackcasino.ui.components + +/** + * Typed result tone a game ViewModel exposes for a settled round, so the + * UI tints the result label from a semantic flag instead of inspecting + * the formatted string (e.g. searching for "+", "-" or "won"). + * + * [Push] covers a tie (Blackjack); games without ties only use + * [Win] / [Loss]. + */ +enum class OutcomeTone { Win, Loss, Push } diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/RulesSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/RulesSheet.kt new file mode 100644 index 0000000..4f16550 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/RulesSheet.kt @@ -0,0 +1,114 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Shared game rules bottom sheet, styled after the mockup's legal sheets + * (Terms / Privacy): a tracked "How to Play" label, a bold [title], a + * [meta] payout line, then the numbered [rules] sections. + * + * Every game's `*RulesSheet` differs only in [title], [meta] and the + * [rules] data, so they reduce to a thin call to this composable. + * + * @param title bold sheet heading, e.g. "Coinflip Rules". + * @param meta tracked payout/odds line shown under the title. + * @param rules ordered (label, body) pairs rendered as numbered sections. + * @param onDismiss invoked when the sheet is dismissed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RulesSheet( + title: String, + meta: String, + rules: List>, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = SurfaceRaised, + contentColor = TextHigh, + ) { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = SheetPadding, end = SheetPadding, bottom = 16.dp), + ) { + Text( + text = "How to Play", + color = AccentViolet, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = title, color = TextHigh, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = meta, + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + HairlineDivider() + Column( + modifier = Modifier.fillMaxWidth().padding(SheetPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + rules.forEach { (label, body) -> RuleSection(label = label, body = body) } + Spacer(modifier = Modifier.height(4.dp)) + HairlineDivider() + Text( + text = "End of rules · Must be 18+ to play", + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), + ) + } + } + } +} + +@Composable +private fun RuleSection( + label: String, + body: String, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = label, color = AccentViolet, fontSize = LabelFontSize, letterSpacing = TrackedLetterSpacing) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = body, color = TextMedium, fontSize = 13.sp, lineHeight = 22.sp) + } +} + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val SheetPadding = 20.dp +private val LabelFontSize = 10.sp +private val MetaFontSize = 9.sp +private val TrackedLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/SearchField.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/SearchField.kt new file mode 100644 index 0000000..92b10a2 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/SearchField.kt @@ -0,0 +1,101 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +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.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +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.SolidColor +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.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow + +/** + * Rounded, outlined single-line search row used by the history and news + * screens: a leading search glyph followed by a [BasicTextField] that + * shows [placeholder] while [value] is empty. + * + * The bordered raised-surface chrome, the violet caret and the leading + * icon column are fixed so every search row looks identical across the + * app; callers only vary [value], [onValueChange] and the [placeholder] + * copy. [enabled] lets the news screen lock the field while its list is + * still loading without forking the component. + * + * @param value current query text. + * @param onValueChange invoked with the new text on every edit. + * @param placeholder hint shown when [value] is empty. + * @param modifier applied to the outer row (e.g. screen padding). + * @param enabled whether the field accepts input. + */ +@Composable +fun SearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(IconColumnWidth).height(InputHeight), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(IconSize), + ) + } + Box( + modifier = Modifier.weight(1f).padding(end = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + enabled = enabled, + textStyle = TextStyle(color = TextHigh, fontSize = FontSize), + cursorBrush = SolidColor(AccentViolet), + modifier = Modifier.fillMaxWidth(), + ) + if (value.isEmpty()) { + Text( + text = placeholder, + color = TextLow, + fontSize = FontSize, + ) + } + } + } +} + +private val IconColumnWidth = 44.dp +private val InputHeight = 40.dp +private val IconSize = 16.dp +private val FontSize = 14.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt index 2512ac7..475e2fe 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,7 +29,7 @@ import com.plainstudio.stackcasino.ui.theme.TextHigh * * Mirrors the mockup `border border-line bg-surface` recipe * (mockup/js/screens/{lobby,wallet,history,profile}.js). Sharp corners - * are enforced project-wide by the theme — the mockup applies + * are enforced project-wide by the theme -- the mockup applies * `* { border-radius:0 !important }` globally. * * Three orthogonal variations capture every observed usage: @@ -100,7 +99,6 @@ private fun StackCardDefaultPreview() { private fun StackCardWithAccentsPreview() { StackcasinoTheme { Box(modifier = Modifier.padding(16.dp)) { - Spacer(modifier = Modifier.padding(top = 0.dp)) StackCard( modifier = Modifier.fillMaxWidth(), leftAccent = SemanticOk, diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackOutlineButton.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackOutlineButton.kt new file mode 100644 index 0000000..7b7e0d7 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackOutlineButton.kt @@ -0,0 +1,58 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 + +/** + * Compact violet outline action button used by the empty and error + * states (e.g. "Clear filters", "Back to news", "Retry"). Renders the + * [label] uppercased and letter-spaced inside a 1.dp [AccentViolet] + * border with a transparent fill, matching the mockup's tracked action + * chips. + * + * The chrome (paddings, tracking, font size and accent color) is fixed + * so every secondary action looks identical; callers only vary the + * [label] and [onClick]. + * + * @param label action text; rendered uppercased. + * @param onClick invoked when the button is tapped. + * @param modifier applied to the outer button surface. + */ +@Composable +fun StackOutlineButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .border(width = 1.dp, color = AccentViolet) + .clickable(onClick = onClick) + .padding(ActionPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label.uppercase(), + color = AccentViolet, + fontSize = ActionFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = ActionLetterSpacing, + ) + } +} + +private val ActionPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp) +private val ActionFontSize = 11.sp +private val ActionLetterSpacing = 1.2.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/StatCell.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StatCell.kt new file mode 100644 index 0000000..8e6752b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StatCell.kt @@ -0,0 +1,49 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TabularNums +import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.ui.theme.TinyFontSize +import com.plainstudio.stackcasino.ui.theme.TrackedLetterSpacing + +/** + * Bordered metric cell used in every game screen's stats strip: a tracked + * [label] over a bold tabular [value] tinted with [valueColor]. + * + * @param label tracked caption, e.g. "Balance". + * @param value formatted metric shown in tabular figures. + * @param valueColor tint applied to [value] (e.g. profit red/green). + */ +@Composable +fun StatCell( + label: String, + value: String, + valueColor: Color, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = value, color = valueColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, style = TabularNums) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/theme/GameTokens.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/theme/GameTokens.kt new file mode 100644 index 0000000..60e58a8 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/theme/GameTokens.kt @@ -0,0 +1,34 @@ +package com.plainstudio.stackcasino.ui.theme + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/* + * Shared layout/typography tokens for the five game screens and their + * chrome (header, stat cells, action buttons). These were previously + * re-declared identically at the bottom of every *Screen.kt; centralizing + * them removes that duplication. Per-screen tokens that genuinely differ + * (e.g. recent-chip size, controls padding) stay local to each screen. + */ + +/** Tabular figures so digit columns do not shift width as values change. */ +val TabularNums = TextStyle(fontFeatureSettings = "tnum") + +/** Drops the font's vertical padding so a single glyph centers in small chips. */ +val CenteredGlyph = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) + +val ScreenHorizontalPadding = 16.dp +val HeaderVerticalPadding = 16.dp +val HeaderButtonSize = 36.dp +val HeaderIconSize = 16.dp +val SubtitleIconSize = 10.dp + +val TinyFontSize = 9.sp +val MetaFontSize = 10.sp +val TrackedLetterSpacing = 1.2.sp + +const val DISABLED_ALPHA = 0.40f +const val CHIP_TINT_ALPHA = 0.15f +const val CHIP_BORDER_ALPHA = 0.40f diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/Money.kt b/app/src/main/java/com/plainstudio/stackcasino/util/Money.kt new file mode 100644 index 0000000..eec9748 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/Money.kt @@ -0,0 +1,19 @@ +package com.plainstudio.stackcasino.util + +import java.util.Locale +import kotlin.math.abs + +/* + * Shared money / multiplier formatting, so the ViewModels do not each + * re-declare the same String.format helpers. Currency-agnostic: the + * caller adds the symbol (the app shows USD-style amounts). + */ + +/** Grouped two-decimal amount without a sign, e.g. `1,234.56`. */ +fun formatMoney(value: Double): String = String.format(Locale.US, "%,.2f", value) + +/** Signed amount with a leading dollar sign, e.g. `+$10.00` / `-$5.50`. */ +fun signedMoney(value: Double): String = (if (value < 0) "-$" else "+$") + formatMoney(abs(value)) + +/** Two-decimal multiplier, e.g. `2.41x`. */ +fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "x" diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/ShareText.kt b/app/src/main/java/com/plainstudio/stackcasino/util/ShareText.kt new file mode 100644 index 0000000..1f769e8 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/ShareText.kt @@ -0,0 +1,20 @@ +package com.plainstudio.stackcasino.util + +import android.content.Context +import android.content.Intent + +/** + * Opens the system share sheet with [text] as plain-text payload. + * + * No-op if no activity can handle the chooser (so the call site never + * crashes on a device without a share target). Shared by every game + * screen's header Share action. + */ +fun Context.shareText(text: String) { + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + runCatching { startActivity(Intent.createChooser(send, null)) } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/TimestampFormat.kt b/app/src/main/java/com/plainstudio/stackcasino/util/TimestampFormat.kt new file mode 100644 index 0000000..fcec11f --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/TimestampFormat.kt @@ -0,0 +1,26 @@ +package com.plainstudio.stackcasino.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Shared UI-facing timestamp formatters, so the ViewModels do not each + * re-declare the same patterns. [SimpleDateFormat] is not thread-safe, so + * a fresh instance is built per call (these are not hot paths). + */ +object TimestampFormat { + /** Ledger style, e.g. `4/16/2026 - 7:30 AM` (wallet, history rows). */ + fun ledger(epochMillis: Long): String = format("M/d/yyyy · h:mm a", epochMillis) + + /** Clock with seconds, e.g. `7:30:14 AM` (round timeline). */ + fun clock(epochMillis: Long): String = format("h:mm:ss a", epochMillis) + + /** Time of day, e.g. `7:30 AM` (chat message stamps). */ + fun timeOfDay(epochMillis: Long): String = format("h:mm a", epochMillis) + + private fun format( + pattern: String, + epochMillis: Long, + ): String = SimpleDateFormat(pattern, Locale.US).format(Date(epochMillis)) +}