From 30b689fb4ed22eab9d032f6c98bf3c4d6f48624f Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:04:46 -0300 Subject: [PATCH] refactor(lobby): split LobbyScreen into per-section files LobbyScreen.kt had grown to ~1100 lines, mixing the screen scaffold with every section composable (header, balance hero, games grid, quick actions, recent activity, loading skeleton). That made it the outlier against Wallet, News and Assistant, which already split their sections into sibling files. Move each section into its own file under feature/lobby, leaving LobbyScreen.kt with the state scaffold, the shared building blocks (Divider, NepFab) and the internal tokens. Extract the repeated background + border boilerplate the tappable cards shared into a `Modifier.lobbyCardSurface` extension. No behavior or layout change: the public LobbyScreen API and the rendered output are identical. --- .../feature/lobby/LobbyBalanceHero.kt | 155 ++++ .../feature/lobby/LobbyGamesSection.kt | 270 ++++++ .../stackcasino/feature/lobby/LobbyHeader.kt | 146 +++ .../stackcasino/feature/lobby/LobbyLoading.kt | 107 +++ .../feature/lobby/LobbyQuickActions.kt | 126 +++ .../feature/lobby/LobbyRecentActivity.kt | 148 +++ .../stackcasino/feature/lobby/LobbyScreen.kt | 842 +----------------- 7 files changed, 982 insertions(+), 812 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyBalanceHero.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyBalanceHero.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyBalanceHero.kt new file mode 100644 index 0000000..fa4e7e0 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyBalanceHero.kt @@ -0,0 +1,155 @@ +package com.plainstudio.stackcasino.feature.lobby + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.BalancePill +import com.plainstudio.stackcasino.ui.components.CurrencyDropdown +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Balance hero: the left column stacks the AVAILABLE label, the today + * P&L chip, session stats, the masked balance pill and the currency + * dropdown; the right column shows the LOCKED stack. + */ +@Composable +internal fun BalanceHero( + balance: BalanceSummary, + session: SessionStats, + isHidden: Boolean, + onToggleHidden: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Column(modifier = Modifier.weight(1f)) { + // Top meta row: AVAILABLE label + PnL chip (chip fades when + // balance is hidden but keeps its slot to avoid layout shift). + BalanceMetaRow( + pnLLabel = balance.todayPnLLabel, + isHidden = isHidden, + ) + Spacer(modifier = Modifier.height(2.dp)) + // Session stats live on their own row below the chip + // (mockup line 52: tracked text-[9px] tabnum following the + // AVAILABLE/chip line via flex-wrap). + Text( + text = "· ${session.rounds} rounds · ${session.wins}W / ${session.losses}L".uppercase(), + color = TextLow, + fontSize = SessionStatsFontSize, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + Spacer(modifier = Modifier.height(4.dp)) + BalancePill( + label = "Available", + amount = balance.amountLabel, + isHidden = isHidden, + onToggleVisibility = onToggleHidden, + ) + Spacer(modifier = Modifier.height(8.dp)) + CurrencyDropdown( + initialCurrency = balance.currencyCode, + networkLabel = balance.networkLabel, + ) + } + LockedColumn(subtitle = balance.lockedSubtitle) + } +} + +@Composable +private fun BalanceMetaRow( + pnLLabel: String?, + isHidden: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "AVAILABLE", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + // The PnL chip fades to alpha 0 when balance is hidden (mockup + // line 46: data-bal-fade) so the row width never shifts. + if (pnLLabel != null) { + PnLChip( + label = pnLLabel, + modifier = Modifier.alpha(if (isHidden) 0f else 1f), + ) + } + } +} + +@Composable +private fun PnLChip( + label: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .background(SemanticOk.copy(alpha = PNL_CHIP_BACKGROUND_ALPHA)) + .border(width = 1.dp, color = SemanticOk.copy(alpha = PNL_CHIP_BORDER_ALPHA)) + .padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = label.uppercase(), + color = SemanticOk, + fontSize = PnlChipFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun LockedColumn(subtitle: String) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = "LOCKED", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = subtitle.uppercase(), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private val SessionStatsFontSize = 9.sp +private val PnlChipFontSize = 9.sp +private const val PNL_CHIP_BACKGROUND_ALPHA = 0.15f +private const val PNL_CHIP_BORDER_ALPHA = 0.40f 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 new file mode 100644 index 0000000..23f2a0d --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt @@ -0,0 +1,270 @@ +package com.plainstudio.stackcasino.feature.lobby + +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.Spacer +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.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.Color +import androidx.compose.ui.res.painterResource +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.model.GameKey +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 + +/** + * Games section: the title row plus a two-column grid of [GameCard]s, + * with Coinflip pinned to its own full-width [CoinflipCard] row at the + * bottom (matches the mockup, which gives Coinflip a wide x2 layout). + */ +@Composable +internal fun GamesSection( + games: List, + onSelectGame: (GameKey) -> Unit, +) { + val gridGames = games.filter { it.key != GameKey.Coinflip } + val coinflip = games.firstOrNull { it.key == GameKey.Coinflip } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + ) { + SectionTitleRow(title = "Games", trailing = "${games.size} available") + Spacer(modifier = Modifier.height(12.dp)) + gridGames.chunked(GAMES_PER_ROW).forEach { rowGames -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GameCardGap), + ) { + rowGames.forEach { card -> + GameCard( + card = card, + onClick = { onSelectGame(card.key) }, + modifier = Modifier.weight(1f), + ) + } + // Fill the remaining cell when the last row is uneven. + repeat(GAMES_PER_ROW - rowGames.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + Spacer(modifier = Modifier.height(GameCardGap)) + } + if (coinflip != null) { + CoinflipCard(card = coinflip, onClick = { onSelectGame(coinflip.key) }) + } + } +} + +@Composable +private fun GameCard( + card: GameCardData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = if (card.isLastPlayed) AccentViolet.copy(alpha = LAST_PLAYED_BACKGROUND_ALPHA) else SurfaceRaised + val borderColor = if (card.isLastPlayed) AccentViolet.copy(alpha = LAST_PLAYED_BORDER_ALPHA) else SurfaceOutline + // Outer box owns the border + clickable so the LAST PLAYED badge + // can sit at an inset smaller than the inner content padding + // (mockup: badge at top-2 left-2 vs content at p-4). + Box( + modifier = + modifier + .lobbyCardSurface(fill = background, stroke = borderColor) + .clickable(onClick = onClick), + ) { + Column(modifier = Modifier.padding(GameCardPadding)) { + if (card.isLastPlayed) { + // Push the icon below the badge. Compensated by a smaller + // icon-to-title gap (see LastPlayedTitleGap below) so the + // Crash card stays the same total height as its siblings + // and the grid row does not stretch unevenly. + Spacer(modifier = Modifier.height(LastPlayedIconTopGap)) + } + Icon( + painter = painterResource(card.key.iconRes()), + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(GameCardIconSize), + ) + Spacer( + modifier = + Modifier.height(if (card.isLastPlayed) LastPlayedTitleGap else GameCardTitleGap), + ) + Text( + text = card.title, + color = TextHigh, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + // alignByBaseline keeps the two labels typographically level + // even though the right side uses `tnum` digits that report + // slightly different line metrics than the plain-letter left. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = card.subtitle.uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.alignByBaseline(), + ) + Text( + text = card.infoRight.uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + modifier = Modifier.alignByBaseline(), + ) + } + } + if (card.isLastPlayed) { + Box( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(LastPlayedBadgeInset) + .background(AccentViolet) + .padding( + horizontal = LastPlayedBadgeHorizontalPadding, + vertical = LastPlayedBadgeVerticalPadding, + ), + ) { + Text( + text = "LAST PLAYED", + color = Color.White, + fontSize = LastPlayedBadgeFontSize, + lineHeight = LastPlayedBadgeFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = LastPlayedBadgeLetterSpacing, + ) + } + } + } +} + +@Composable +private fun CoinflipCard( + card: GameCardData, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .lobbyCardSurface() + .clickable(onClick = onClick) + .padding(GameCardPadding), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = + Modifier + .size(CoinflipBadgeSize) + .border(width = 1.dp, color = AccentViolet), + contentAlignment = Alignment.Center, + ) { + Text( + text = "x2", + color = AccentViolet, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = card.title, + color = TextHigh, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = card.subtitle.uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + // Aligns with the subtitle line of the left column instead of + // the row centre so the meta label sits on the same baseline + // as X2 PAYOUT (mockup line 173 leaves both labels at the + // foot of the card). + Text( + text = card.infoRight.uppercase(), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.align(Alignment.Bottom), + ) + } + } +} + +@Composable +private fun SectionTitleRow( + title: String, + trailing: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title.uppercase(), + color = TextMedium, + fontSize = SectionTitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = trailing.uppercase(), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private val GameCardIconSize = 28.dp +private val LastPlayedIconTopGap = 16.dp +private val LastPlayedTitleGap = 16.dp +private val LastPlayedBadgeInset = 8.dp +private val CoinflipBadgeSize = 40.dp +private const val GAMES_PER_ROW = 2 + +private val LastPlayedBadgeFontSize = 7.sp +private val LastPlayedBadgeLetterSpacing = 0.6.sp +private val LastPlayedBadgeHorizontalPadding = 4.dp +private val LastPlayedBadgeVerticalPadding = 1.dp + +private const val LAST_PLAYED_BACKGROUND_ALPHA = 0.05f +private const val LAST_PLAYED_BORDER_ALPHA = 0.50f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt new file mode 100644 index 0000000..b27f364 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt @@ -0,0 +1,146 @@ +package com.plainstudio.stackcasino.feature.lobby + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +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.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.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +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.TextMedium + +/** + * Lobby top bar: avatar tile, greeting + display name, and the + * notifications button with its unread dot. + */ +@Composable +internal fun LobbyHeader( + user: UserSummary, + onOpenNotifications: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = HeaderTopPadding, + bottom = HeaderBottomPadding, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AvatarTile(displayName = user.displayName) + Column(modifier = Modifier.weight(1f)) { + Text( + text = user.greeting.uppercase(), + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = user.displayName, + color = TextHigh, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + NotificationsButton( + hasUnread = user.hasUnreadNotifications, + onClick = onOpenNotifications, + ) + } +} + +@Composable +private fun AvatarTile(displayName: String) { + Box( + modifier = Modifier.size(AvatarSize).background(AccentViolet), + contentAlignment = Alignment.Center, + ) { + Text( + text = initialsOf(displayName), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun NotificationsButton( + hasUnread: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(NotificationsButtonSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Notifications, + contentDescription = "Notifications", + tint = TextHigh, + modifier = Modifier.size(NotificationsIconSize), + ) + if (hasUnread) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(NotificationsBadgeInset) + .size(NotificationsBadgeSize) + .background(SemanticDanger), + ) + } + } +} + +/** + * Splits the display name on whitespace and joins the first letter of + * the first two tokens for the avatar tile (e.g. "John Doe" -> "JD"). + * Falls back to the first character when only a single word is present. + */ +private fun initialsOf(name: String): String { + val tokens = name.split(' ').filter { it.isNotBlank() } + return when (tokens.size) { + 0 -> "" + 1 -> tokens[0].first().uppercase() + else -> "${tokens[0].first().uppercase()}${tokens[1].first().uppercase()}" + } +} + +private val HeaderTopPadding = 24.dp +private val HeaderBottomPadding = 16.dp +private val AvatarSize = 40.dp +private val NotificationsButtonSize = 40.dp +private val NotificationsIconSize = 18.dp +private val NotificationsBadgeSize = 8.dp +private val NotificationsBadgeInset = 6.dp 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 new file mode 100644 index 0000000..4820948 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyLoading.kt @@ -0,0 +1,107 @@ +package com.plainstudio.stackcasino.feature.lobby + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.Skeleton + +/** + * Skeleton placeholder shown while the lobby data loads. Mirrors the + * success layout's rhythm (balance block, games grid, recent list) so + * the content does not visibly jump when the real data arrives. + */ +@Composable +internal fun LoadingContent() { + Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + // Balance skeleton. + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Skeleton(modifier = Modifier.size(width = 96.dp, height = 8.dp)) + Skeleton(modifier = Modifier.size(width = 224.dp, height = 40.dp)) + Skeleton(modifier = Modifier.size(width = 112.dp, height = 8.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End) { + Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) + Skeleton(modifier = Modifier.size(width = 80.dp, height = 8.dp)) + } + } + Divider() + // Games skeleton. + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + ) { + Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + repeat(2) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GameCardGap), + ) { + Skeleton( + modifier = + Modifier + .weight(1f) + .height(GameCardSkeletonHeight), + ) + Skeleton( + modifier = + Modifier + .weight(1f) + .height(GameCardSkeletonHeight), + ) + } + Spacer(modifier = Modifier.height(GameCardGap)) + } + Skeleton(modifier = Modifier.fillMaxWidth().height(CoinflipSkeletonHeight)) + } + Divider() + // Recent skeleton. + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = SectionVerticalPadding, + bottom = BottomScrollPadding, + ), + ) { + Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) + Spacer(modifier = Modifier.height(12.dp)) + repeat(RECENT_SKELETON_COUNT) { + Skeleton(modifier = Modifier.fillMaxWidth().height(RecentSkeletonHeight)) + if (it < RECENT_SKELETON_COUNT - 1) { + Spacer(modifier = Modifier.height(RecentRowGap)) + } + } + } + } +} + +private val GameCardSkeletonHeight = 112.dp +private val CoinflipSkeletonHeight = 80.dp +private val RecentSkeletonHeight = 56.dp +private const val RECENT_SKELETON_COUNT = 3 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 new file mode 100644 index 0000000..7086756 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyQuickActions.kt @@ -0,0 +1,126 @@ +package com.plainstudio.stackcasino.feature.lobby + +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material3.Icon +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.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SemanticWarn +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +// Tab keys the lobby passes back to the nav-host so wallet deep-links +// land on the right pane. Match [WalletTab.name.lowercase()]. +internal const val QUICK_ACTION_TAB_DEPOSIT = "Deposit" +internal const val QUICK_ACTION_TAB_WITHDRAW = "Withdraw" + +/** + * Quick actions section: a Deposit / Withdraw pair that deep-links into + * the wallet tabs. + */ +@Composable +internal fun QuickActionsSection( + onOpenDeposit: () -> Unit, + onOpenWithdraw: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + ) { + Text( + text = "QUICK ACTIONS", + color = TextMedium, + fontSize = SectionTitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GameCardGap), + ) { + QuickActionCard( + title = "Deposit", + subtitle = "Receive crypto", + accent = SemanticOk, + isDeposit = true, + onClick = onOpenDeposit, + modifier = Modifier.weight(1f), + ) + QuickActionCard( + title = "Withdraw", + subtitle = "Send to address", + accent = SemanticWarn, + isDeposit = false, + onClick = onOpenWithdraw, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun QuickActionCard( + title: String, + subtitle: String, + accent: Color, + isDeposit: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .lobbyCardSurface() + .clickable(onClick = onClick) + .padding(GameCardPadding), + ) { + Column { + // Mockup deposits land downward (incoming) and withdrawals + // leave upward (outgoing); see mockup/js/screens/lobby.js + // lines 186 and 191. + Icon( + imageVector = + if (isDeposit) Icons.Outlined.ArrowDownward else Icons.Outlined.ArrowUpward, + contentDescription = null, + tint = accent, + modifier = Modifier.size(QuickActionIconSize), + ) + Spacer(modifier = Modifier.height(GameCardTitleGap)) + Text( + text = title, + color = TextHigh, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle.uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +private val QuickActionIconSize = 20.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt new file mode 100644 index 0000000..35ae0ac --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt @@ -0,0 +1,148 @@ +package com.plainstudio.stackcasino.feature.lobby + +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.Row +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.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +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.res.painterResource +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.model.RoundOutcome +import com.plainstudio.stackcasino.ui.components.StackCard +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.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Recent activity section: the RECENT title with a VIEW ALL affordance + * (deep-links to History) over a short list of [RecentRoundRow]s. + */ +@Composable +internal fun RecentActivitySection( + rounds: List, + onViewAll: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "RECENT", + color = TextMedium, + fontSize = SectionTitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Row( + modifier = Modifier.clickable(onClick = onViewAll), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "VIEW ALL", + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(ViewAllChevronSize), + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(RecentRowGap)) { + rounds.forEach { round -> + RecentRoundRow(round = round) + } + } + } +} + +@Composable +private fun RecentRoundRow(round: RecentRound) { + val accent = if (round.outcome == RoundOutcome.Win) SemanticOk else SemanticDanger + val amountColor = if (round.outcome == RoundOutcome.Win) SemanticOk else TextLow + StackCard( + modifier = Modifier.fillMaxWidth(), + leftAccent = accent, + contentPadding = PaddingValues(RecentRowPadding), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .size(RecentRowIconBoxSize) + .border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(round.game.iconRes()), + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(RecentRowIconSize), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = round.gameLabel, + color = TextHigh, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${round.agoLabel} · ${round.resultLabel}".uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + Text( + text = round.amountLabel, + color = amountColor, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + } +} + +private val RecentRowPadding = 12.dp +private val RecentRowIconBoxSize = 36.dp +private val RecentRowIconSize = 16.dp +private val ViewAllChevronSize = 12.dp 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 62a4c9f..7ab996e 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 @@ -4,11 +4,9 @@ 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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -17,14 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowForward -import androidx.compose.material.icons.outlined.ArrowDownward -import androidx.compose.material.icons.outlined.ArrowUpward -import androidx.compose.material.icons.outlined.Notifications -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.runtime.mutableStateOf @@ -32,37 +23,23 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.R import com.plainstudio.stackcasino.model.GameKey -import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.navigation.Route -import com.plainstudio.stackcasino.ui.components.BalancePill -import com.plainstudio.stackcasino.ui.components.CurrencyDropdown import com.plainstudio.stackcasino.ui.components.ErrorState import com.plainstudio.stackcasino.ui.components.ErrorStateDefaults -import com.plainstudio.stackcasino.ui.components.Skeleton -import com.plainstudio.stackcasino.ui.components.StackCard import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.AccentViolet -import com.plainstudio.stackcasino.ui.theme.SemanticDanger -import com.plainstudio.stackcasino.ui.theme.SemanticOk -import com.plainstudio.stackcasino.ui.theme.SemanticWarn import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme import com.plainstudio.stackcasino.ui.theme.SurfaceBase import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.SurfaceRaised -import com.plainstudio.stackcasino.ui.theme.TextHigh -import com.plainstudio.stackcasino.ui.theme.TextLow -import com.plainstudio.stackcasino.ui.theme.TextMedium /** * Lobby screen reproducing the cu-03 mockup @@ -79,6 +56,11 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium * eye-toggle for balance visibility lives as local UI-only state * (it is purely presentational and survives configuration changes via * rememberSaveable). + * + * The per-section composables live in dedicated sibling files + * (LobbyHeader, LobbyBalanceHero, LobbyGamesSection, LobbyQuickActions, + * LobbyRecentActivity, LobbyLoading) so this file stays focused on the + * screen-level scaffold and the shared building blocks below. */ @Composable fun LobbyScreen( @@ -135,10 +117,6 @@ private fun LobbyContent( } } -// --------------------------------------------------------------------------- -// Success state -// --------------------------------------------------------------------------- - @Composable private fun SuccessContent( data: LobbyData, @@ -179,706 +157,6 @@ private fun SuccessContent( } } -@Composable -private fun LobbyHeader( - user: UserSummary, - onOpenNotifications: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = ScreenHorizontalPadding, - end = ScreenHorizontalPadding, - top = HeaderTopPadding, - bottom = HeaderBottomPadding, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AvatarTile(displayName = user.displayName) - Column(modifier = Modifier.weight(1f)) { - Text( - text = user.greeting.uppercase(), - color = TextMedium, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = user.displayName, - color = TextHigh, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) - } - NotificationsButton( - hasUnread = user.hasUnreadNotifications, - onClick = onOpenNotifications, - ) - } -} - -@Composable -private fun AvatarTile(displayName: String) { - Box( - modifier = Modifier.size(AvatarSize).background(AccentViolet), - contentAlignment = Alignment.Center, - ) { - Text( - text = initialsOf(displayName), - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - ) - } -} - -@Composable -private fun NotificationsButton( - hasUnread: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(NotificationsButtonSize) - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Notifications, - contentDescription = "Notifications", - tint = TextHigh, - modifier = Modifier.size(NotificationsIconSize), - ) - if (hasUnread) { - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(NotificationsBadgeInset) - .size(NotificationsBadgeSize) - .background(SemanticDanger), - ) - } - } -} - -@Composable -private fun BalanceHero( - balance: BalanceSummary, - session: SessionStats, - isHidden: Boolean, - onToggleHidden: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - Column(modifier = Modifier.weight(1f)) { - // Top meta row: AVAILABLE label + PnL chip (chip fades when - // balance is hidden but keeps its slot to avoid layout shift). - BalanceMetaRow( - pnLLabel = balance.todayPnLLabel, - isHidden = isHidden, - ) - Spacer(modifier = Modifier.height(2.dp)) - // Session stats live on their own row below the chip - // (mockup line 52: tracked text-[9px] tabnum following the - // AVAILABLE/chip line via flex-wrap). - Text( - text = "· ${session.rounds} rounds · ${session.wins}W / ${session.losses}L".uppercase(), - color = TextLow, - fontSize = SessionStatsFontSize, - letterSpacing = TrackedLetterSpacing, - style = TextStyle(fontFeatureSettings = "tnum"), - ) - Spacer(modifier = Modifier.height(4.dp)) - BalancePill( - label = "Available", - amount = balance.amountLabel, - isHidden = isHidden, - onToggleVisibility = onToggleHidden, - ) - Spacer(modifier = Modifier.height(8.dp)) - CurrencyDropdown( - initialCurrency = balance.currencyCode, - networkLabel = balance.networkLabel, - ) - } - LockedColumn(subtitle = balance.lockedSubtitle) - } -} - -@Composable -private fun BalanceMetaRow( - pnLLabel: String?, - isHidden: Boolean, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "AVAILABLE", - color = TextMedium, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - // The PnL chip fades to alpha 0 when balance is hidden (mockup - // line 46: data-bal-fade) so the row width never shifts. - if (pnLLabel != null) { - PnLChip( - label = pnLLabel, - modifier = Modifier.alpha(if (isHidden) 0f else 1f), - ) - } - } -} - -@Composable -private fun PnLChip( - label: String, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .background(SemanticOk.copy(alpha = PNL_CHIP_BACKGROUND_ALPHA)) - .border(width = 1.dp, color = SemanticOk.copy(alpha = PNL_CHIP_BORDER_ALPHA)) - .padding(horizontal = 6.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = label.uppercase(), - color = SemanticOk, - fontSize = PnlChipFontSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - style = TextStyle(fontFeatureSettings = "tnum"), - ) - } -} - -@Composable -private fun LockedColumn(subtitle: String) { - Column(horizontalAlignment = Alignment.End) { - Text( - text = "LOCKED", - color = TextMedium, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = subtitle.uppercase(), - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } -} - -// --------------------------------------------------------------------------- -// Games section -// --------------------------------------------------------------------------- - -@Composable -private fun GamesSection( - games: List, - onSelectGame: (GameKey) -> Unit, -) { - val gridGames = games.filter { it.key != GameKey.Coinflip } - val coinflip = games.firstOrNull { it.key == GameKey.Coinflip } - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - ) { - SectionTitleRow(title = "Games", trailing = "${games.size} available") - Spacer(modifier = Modifier.height(12.dp)) - gridGames.chunked(GAMES_PER_ROW).forEach { rowGames -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GameCardGap), - ) { - rowGames.forEach { card -> - GameCard( - card = card, - onClick = { onSelectGame(card.key) }, - modifier = Modifier.weight(1f), - ) - } - // Fill the remaining cell when the last row is uneven. - repeat(GAMES_PER_ROW - rowGames.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } - Spacer(modifier = Modifier.height(GameCardGap)) - } - if (coinflip != null) { - CoinflipCard(card = coinflip, onClick = { onSelectGame(coinflip.key) }) - } - } -} - -@Composable -private fun GameCard( - card: GameCardData, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val background = if (card.isLastPlayed) AccentViolet.copy(alpha = LAST_PLAYED_BACKGROUND_ALPHA) else SurfaceRaised - val borderColor = if (card.isLastPlayed) AccentViolet.copy(alpha = LAST_PLAYED_BORDER_ALPHA) else SurfaceOutline - // Outer box owns the border + clickable so the LAST PLAYED badge - // can sit at an inset smaller than the inner content padding - // (mockup: badge at top-2 left-2 vs content at p-4). - Box( - modifier = - modifier - .background(background) - .border(width = 1.dp, color = borderColor) - .clickable(onClick = onClick), - ) { - Column(modifier = Modifier.padding(GameCardPadding)) { - if (card.isLastPlayed) { - // Push the icon below the badge. Compensated by a smaller - // icon-to-title gap (see LastPlayedTitleGap below) so the - // Crash card stays the same total height as its siblings - // and the grid row does not stretch unevenly. - Spacer(modifier = Modifier.height(LastPlayedIconTopGap)) - } - Icon( - painter = painterResource(card.key.iconRes()), - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(GameCardIconSize), - ) - Spacer( - modifier = - Modifier.height(if (card.isLastPlayed) LastPlayedTitleGap else GameCardTitleGap), - ) - Text( - text = card.title, - color = TextHigh, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(8.dp)) - // alignByBaseline keeps the two labels typographically level - // even though the right side uses `tnum` digits that report - // slightly different line metrics than the plain-letter left. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = card.subtitle.uppercase(), - color = TextLow, - fontSize = SmallMetaFontSize, - letterSpacing = TrackedLetterSpacing, - modifier = Modifier.alignByBaseline(), - ) - Text( - text = card.infoRight.uppercase(), - color = TextLow, - fontSize = SmallMetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TextStyle(fontFeatureSettings = "tnum"), - modifier = Modifier.alignByBaseline(), - ) - } - } - if (card.isLastPlayed) { - Box( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(LastPlayedBadgeInset) - .background(AccentViolet) - .padding( - horizontal = LastPlayedBadgeHorizontalPadding, - vertical = LastPlayedBadgeVerticalPadding, - ), - ) { - Text( - text = "LAST PLAYED", - color = Color.White, - fontSize = LastPlayedBadgeFontSize, - lineHeight = LastPlayedBadgeFontSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = LastPlayedBadgeLetterSpacing, - ) - } - } - } -} - -@Composable -private fun CoinflipCard( - card: GameCardData, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick) - .padding(GameCardPadding), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Box( - modifier = - Modifier - .size(CoinflipBadgeSize) - .border(width = 1.dp, color = AccentViolet), - contentAlignment = Alignment.Center, - ) { - Text( - text = "x2", - color = AccentViolet, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = card.title, - color = TextHigh, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = card.subtitle.uppercase(), - color = TextLow, - fontSize = SmallMetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - // Aligns with the subtitle line of the left column instead of - // the row centre so the meta label sits on the same baseline - // as X2 PAYOUT (mockup line 173 leaves both labels at the - // foot of the card). - Text( - text = card.infoRight.uppercase(), - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - modifier = Modifier.align(Alignment.Bottom), - ) - } - } -} - -// --------------------------------------------------------------------------- -// Quick actions -// --------------------------------------------------------------------------- - -@Composable -private fun QuickActionsSection( - onOpenDeposit: () -> Unit, - onOpenWithdraw: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - ) { - Text( - text = "QUICK ACTIONS", - color = TextMedium, - fontSize = SectionTitleFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GameCardGap), - ) { - QuickActionCard( - title = "Deposit", - subtitle = "Receive crypto", - accent = SemanticOk, - isDeposit = true, - onClick = onOpenDeposit, - modifier = Modifier.weight(1f), - ) - QuickActionCard( - title = "Withdraw", - subtitle = "Send to address", - accent = SemanticWarn, - isDeposit = false, - onClick = onOpenWithdraw, - modifier = Modifier.weight(1f), - ) - } - } -} - -// Tab keys the lobby passes back to the nav-host so wallet deep-links -// land on the right pane. Match [WalletTab.name.lowercase()]. -internal const val QUICK_ACTION_TAB_DEPOSIT = "Deposit" -internal const val QUICK_ACTION_TAB_WITHDRAW = "Withdraw" - -@Composable -private fun QuickActionCard( - title: String, - subtitle: String, - accent: Color, - isDeposit: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = - modifier - .background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline) - .clickable(onClick = onClick) - .padding(GameCardPadding), - ) { - Column { - // Mockup deposits land downward (incoming) and withdrawals - // leave upward (outgoing); see mockup/js/screens/lobby.js - // lines 186 and 191. - Icon( - imageVector = - if (isDeposit) Icons.Outlined.ArrowDownward else Icons.Outlined.ArrowUpward, - contentDescription = null, - tint = accent, - modifier = Modifier.size(QuickActionIconSize), - ) - Spacer(modifier = Modifier.height(GameCardTitleGap)) - Text( - text = title, - color = TextHigh, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = subtitle.uppercase(), - color = TextLow, - fontSize = SmallMetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } - } -} - -// --------------------------------------------------------------------------- -// Recent activity -// --------------------------------------------------------------------------- - -@Composable -private fun RecentActivitySection( - rounds: List, - onViewAll: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "RECENT", - color = TextMedium, - fontSize = SectionTitleFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Row( - modifier = Modifier.clickable(onClick = onViewAll), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "VIEW ALL", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowForward, - contentDescription = null, - tint = TextLow, - modifier = Modifier.size(ViewAllChevronSize), - ) - } - } - Spacer(modifier = Modifier.height(12.dp)) - Column(verticalArrangement = Arrangement.spacedBy(RecentRowGap)) { - rounds.forEach { round -> - RecentRoundRow(round = round) - } - } - } -} - -@Composable -private fun RecentRoundRow(round: RecentRound) { - val accent = if (round.outcome == RoundOutcome.Win) SemanticOk else SemanticDanger - val amountColor = if (round.outcome == RoundOutcome.Win) SemanticOk else TextLow - StackCard( - modifier = Modifier.fillMaxWidth(), - leftAccent = accent, - contentPadding = PaddingValues(RecentRowPadding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - modifier = - Modifier - .size(RecentRowIconBoxSize) - .border(width = 1.dp, color = SurfaceOutline), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(round.game.iconRes()), - contentDescription = null, - tint = AccentViolet, - modifier = Modifier.size(RecentRowIconSize), - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = round.gameLabel, - color = TextHigh, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "${round.agoLabel} · ${round.resultLabel}".uppercase(), - color = TextLow, - fontSize = SmallMetaFontSize, - letterSpacing = TrackedLetterSpacing, - style = TextStyle(fontFeatureSettings = "tnum"), - ) - } - Text( - text = round.amountLabel, - color = amountColor, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - style = TextStyle(fontFeatureSettings = "tnum"), - ) - } - } -} - -// --------------------------------------------------------------------------- -// Loading state -// --------------------------------------------------------------------------- - -@Composable -private fun LoadingContent() { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - // Balance skeleton. - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Skeleton(modifier = Modifier.size(width = 96.dp, height = 8.dp)) - Skeleton(modifier = Modifier.size(width = 224.dp, height = 40.dp)) - Skeleton(modifier = Modifier.size(width = 112.dp, height = 8.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End) { - Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) - Skeleton(modifier = Modifier.size(width = 80.dp, height = 8.dp)) - } - } - Divider() - // Games skeleton. - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), - ) { - Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) - Spacer(modifier = Modifier.height(16.dp)) - repeat(2) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GameCardGap), - ) { - Skeleton( - modifier = - Modifier - .weight(1f) - .height(GameCardSkeletonHeight), - ) - Skeleton( - modifier = - Modifier - .weight(1f) - .height(GameCardSkeletonHeight), - ) - } - Spacer(modifier = Modifier.height(GameCardGap)) - } - Skeleton(modifier = Modifier.fillMaxWidth().height(CoinflipSkeletonHeight)) - } - Divider() - // Recent skeleton. - Column( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = ScreenHorizontalPadding, - end = ScreenHorizontalPadding, - top = SectionVerticalPadding, - bottom = BottomScrollPadding, - ), - ) { - Skeleton(modifier = Modifier.size(width = 64.dp, height = 8.dp)) - Spacer(modifier = Modifier.height(12.dp)) - repeat(RECENT_SKELETON_COUNT) { - Skeleton(modifier = Modifier.fillMaxWidth().height(RecentSkeletonHeight)) - if (it < RECENT_SKELETON_COUNT - 1) { - Spacer(modifier = Modifier.height(RecentRowGap)) - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Error state -// --------------------------------------------------------------------------- - @Composable private fun ErrorContent( state: LobbyUiState.Error, @@ -906,8 +184,19 @@ 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 -private fun Divider() { +internal fun Divider() { Box( modifier = Modifier @@ -917,31 +206,6 @@ private fun Divider() { ) } -@Composable -private fun SectionTitleRow( - title: String, - trailing: String, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title.uppercase(), - color = TextMedium, - fontSize = SectionTitleFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Text( - text = trailing.uppercase(), - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - } -} - @Composable private fun NepFab( onClick: () -> Unit, @@ -977,7 +241,7 @@ private fun GameKey.toRoute(): Route = GameKey.Coinflip -> Route.Coinflip } -private fun GameKey.iconRes(): Int = +internal fun GameKey.iconRes(): Int = when (this) { GameKey.Roulette -> R.drawable.ic_game_roulette GameKey.Blackjack -> R.drawable.ic_game_blackjack @@ -986,75 +250,29 @@ private fun GameKey.iconRes(): Int = GameKey.Coinflip -> R.drawable.ic_game_coinflip } -private fun initialsOf(name: String): String { - val tokens = name.split(' ').filter { it.isNotBlank() } - return when (tokens.size) { - 0 -> "" - 1 -> tokens[0].first().uppercase() - else -> "${tokens[0].first().uppercase()}${tokens[1].first().uppercase()}" - } -} - // --------------------------------------------------------------------------- -// Tokens +// Tokens shared across the lobby section files via the `internal` modifier. // --------------------------------------------------------------------------- -private val ScreenHorizontalPadding = 16.dp -private val SectionVerticalPadding = 20.dp -private val HeaderTopPadding = 24.dp -private val HeaderBottomPadding = 16.dp -private val BottomScrollPadding = 96.dp - -private val AvatarSize = 40.dp -private val NotificationsButtonSize = 40.dp -private val NotificationsIconSize = 18.dp -private val NotificationsBadgeSize = 8.dp -private val NotificationsBadgeInset = 6.dp - -private val GameCardPadding = 16.dp -private val GameCardGap = 8.dp -private val GameCardIconSize = 28.dp -private val GameCardTitleGap = 32.dp -private val GameCardSkeletonHeight = 112.dp -private val LastPlayedIconTopGap = 16.dp -private val LastPlayedTitleGap = 16.dp -private val LastPlayedBadgeInset = 8.dp -private val CoinflipSkeletonHeight = 80.dp -private val CoinflipBadgeSize = 40.dp +internal val ScreenHorizontalPadding = 16.dp +internal val SectionVerticalPadding = 20.dp +internal val BottomScrollPadding = 96.dp -private val QuickActionIconSize = 20.dp +internal val GameCardPadding = 16.dp +internal val GameCardGap = 8.dp +internal val GameCardTitleGap = 32.dp -private val RecentRowGap = 8.dp -private val RecentRowPadding = 12.dp -private val RecentRowIconBoxSize = 36.dp -private val RecentRowIconSize = 16.dp -private val RecentSkeletonHeight = 56.dp -private const val RECENT_SKELETON_COUNT = 3 +internal val RecentRowGap = 8.dp -private val ViewAllChevronSize = 12.dp +internal val MetaFontSize = 10.sp +internal val SmallMetaFontSize = 9.sp +internal val SectionTitleFontSize = 11.sp +internal val TrackedLetterSpacing = 1.2.sp private val NepFabSize = 48.dp private val NepFabBorderWidth = 2.dp private val NepFabPadding = PaddingValues(end = 16.dp, bottom = 16.dp) -private const val GAMES_PER_ROW = 2 - -private val MetaFontSize = 10.sp -private val SmallMetaFontSize = 9.sp -private val SectionTitleFontSize = 11.sp -private val SessionStatsFontSize = 9.sp -private val PnlChipFontSize = 9.sp -private val LastPlayedBadgeFontSize = 7.sp -private val LastPlayedBadgeLetterSpacing = 0.6.sp -private val LastPlayedBadgeHorizontalPadding = 4.dp -private val LastPlayedBadgeVerticalPadding = 1.dp -private val TrackedLetterSpacing = 1.2.sp - -private const val PNL_CHIP_BACKGROUND_ALPHA = 0.15f -private const val PNL_CHIP_BORDER_ALPHA = 0.40f -private const val LAST_PLAYED_BACKGROUND_ALPHA = 0.05f -private const val LAST_PLAYED_BORDER_ALPHA = 0.50f - // --------------------------------------------------------------------------- // Previews // ---------------------------------------------------------------------------