From 69a352084a8f7ede5a28e3b36cdf75ad38aed4e0 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Wed, 27 May 2026 07:11:27 -0300 Subject: [PATCH] feat(ui): add the 6 reusable UI components consumed by the primary screens The next round of feature work (Lobby, Wallet, History, Profile shells) all share the same building blocks. Building them once here keeps each screen PR focused on layout rather than re-implementing the same card or chip styling five times. Every component below is mockup-faithful; spec was extracted directly from mockup/js/components.js, mockup/js/screens/*.js and mockup/styles.css. * StackCard - generic bordered container (border-line bg-surface). Slots a single content lambda; optional leftAccent draws the 3dp win/loss/deposit/withdrawal strip; elevated swaps surface tone; onClick toggles ripple feedback. Defaults to 16dp content padding (p-4 in the mockup), overridable via contentPadding. * BalancePill - balance hero (lobby + wallet headers). Uppercased label + 36sp bold amount with tabular-nums; isHidden swaps the amount for six dots; onToggleVisibility wires the eye icon when provided, hides the control otherwise (wallet's locked-balance row is read-only). * FilterChipRow - single-select chip row. Generic key type keeps callers type-safe (enums, sealed types, ids). Active chips use violet fill + white text + violet border; inactive ones are transparent with txt-mid label and line border. scrollable=true swaps the wrapped Row for a horizontal-scroll lane (news source filters use this; wallet tabs do not). * EmptyState - centered icon + uppercased title + message + optional CTA. Icon slot lets the caller choose imageVector / drawable per context (history, KYC, filtered news). Reuses the mockup recipe: 56dp icon container, max-width 280dp, vertical py-12 padding. * ErrorState - danger-tinted container with icon, title, message, required primary action plus optional secondary action and footer text. Covers the three observed flavors: profile/news single-CTA, lobby dual-CTA (Retry + Use cache), news with footer ("Last successful fetch 2h ago"). Action buttons share an internal ErrorActionButton helper so the styling stays one source of truth. * Skeleton - loading-placeholder primitive. Ports Tailwind's animate-pulse exactly: opacity 1 -> 0.5 -> 1 over 1000ms using cubic-bezier(0.4, 0, 0.6, 1). The caller decides the shape via the Modifier (Modifier.size(...), Modifier.fillMaxWidth().height(...), etc.), which matches the mockup's inline placeholders where every screen sizes the block to its target slot. Each file ships @Preview composables for every state variant so the Android Studio preview pane covers the design surface. Convention adhered to per CR feedback: * const val tokens use SCREAMING_SNAKE_CASE; plain val tokens use PascalCase (matches ktlint property-naming). * Every numeric literal lives at the bottom of its file as a named token; no inline magic numbers in the production composables. * detekt.yml: extend the @Preview exemption to MagicNumber too - preview composables intentionally use throwaway fractional widths to demonstrate variants. Tests (instrumented, run via emulator lane once card 15 lands): * FilterChipRowTest: every label renders uppercased; selection callback fires with the typed key on click. * ErrorStateTest: title/message/primary render; primary click invokes callback; secondary action gates on both label+callback; footer is uppercased when supplied. * BalancePillTest: label uppercased; hidden state swaps to six dots with the show/hide content description; toggle click flips state; eye control absent when callback is null. --- .../ui/components/BalancePillTest.kt | 88 ++++++ .../ui/components/ErrorStateTest.kt | 101 +++++++ .../ui/components/FilterChipRowTest.kt | 58 ++++ .../stackcasino/ui/components/BalancePill.kt | 145 ++++++++++ .../stackcasino/ui/components/EmptyState.kt | 168 ++++++++++++ .../stackcasino/ui/components/ErrorState.kt | 252 ++++++++++++++++++ .../ui/components/FilterChipRow.kt | 151 +++++++++++ .../stackcasino/ui/components/Skeleton.kt | 105 ++++++++ .../stackcasino/ui/components/StackCard.kt | 129 +++++++++ config/detekt/detekt.yml | 3 + 10 files changed, 1200 insertions(+) create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/BalancePillTest.kt create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/ErrorStateTest.kt create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/FilterChipRowTest.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/Skeleton.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/BalancePillTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/BalancePillTest.kt new file mode 100644 index 0000000..cef0aaf --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/BalancePillTest.kt @@ -0,0 +1,88 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BalancePillTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun renders_label_uppercased_and_amount_verbatim() { + composeRule.setContent { + StackcasinoTheme { + BalancePill(label = "Available", amount = "$1,248.50") + } + } + + composeRule.onNodeWithText("AVAILABLE").assertIsDisplayed() + composeRule.onNodeWithText("$1,248.50").assertIsDisplayed() + } + + @Test + fun hidden_state_replaces_amount_with_dot_placeholder() { + composeRule.setContent { + StackcasinoTheme { + BalancePill( + label = "Available", + amount = "$1,248.50", + isHidden = true, + onToggleVisibility = {}, + ) + } + } + + composeRule.onAllNodesWithContentDescription("Hide balance").assertCountEquals(0) + composeRule.onNodeWithContentDescription("Show balance").assertIsDisplayed() + composeRule.onNodeWithText("••••••").assertIsDisplayed() + } + + @Test + fun toggle_click_invokes_callback() { + val hidden = mutableStateOf(false) + composeRule.setContent { + StackcasinoTheme { + BalancePill( + label = "Available", + amount = "$1,248.50", + isHidden = hidden.value, + onToggleVisibility = { hidden.value = !hidden.value }, + ) + } + } + + composeRule.onNodeWithContentDescription("Hide balance").performClick() + composeRule.waitForIdle() + assertTrue("Toggle should have flipped to hidden.", hidden.value) + + composeRule.onNodeWithContentDescription("Show balance").performClick() + composeRule.waitForIdle() + assertEquals(false, hidden.value) + } + + @Test + fun toggle_is_absent_when_callback_is_null() { + composeRule.setContent { + StackcasinoTheme { + BalancePill(label = "Locked", amount = "$0.00") + } + } + + composeRule.onAllNodesWithContentDescription("Hide balance").assertCountEquals(0) + composeRule.onAllNodesWithContentDescription("Show balance").assertCountEquals(0) + } +} diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/ErrorStateTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/ErrorStateTest.kt new file mode 100644 index 0000000..05acf11 --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/ErrorStateTest.kt @@ -0,0 +1,101 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ErrorStateTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun renders_title_message_and_primary_action() { + composeRule.setContent { + StackcasinoTheme { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Couldn't load profile", + message = "Sync with Firestore failed.", + primaryActionLabel = "Retry", + onPrimaryAction = {}, + ) + } + } + + composeRule.onNodeWithText("Couldn't load profile").assertIsDisplayed() + composeRule.onNodeWithText("Sync with Firestore failed.").assertIsDisplayed() + composeRule.onNodeWithText("RETRY").assertIsDisplayed() + } + + @Test + fun primary_action_click_invokes_callback() { + var clicks = 0 + composeRule.setContent { + StackcasinoTheme { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Boom", + message = "Try again.", + primaryActionLabel = "Retry", + onPrimaryAction = { clicks += 1 }, + ) + } + } + + composeRule.onNodeWithText("RETRY").performClick() + composeRule.waitForIdle() + + assertEquals(1, clicks) + } + + @Test + fun secondary_action_renders_only_when_both_label_and_callback_provided() { + var primary = 0 + var secondary = 0 + composeRule.setContent { + StackcasinoTheme { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Connection Lost", + message = "Offline.", + primaryActionLabel = "Retry", + onPrimaryAction = { primary += 1 }, + secondaryActionLabel = "Use cache", + onSecondaryAction = { secondary += 1 }, + ) + } + } + + composeRule.onNodeWithText("USE CACHE").assertIsDisplayed().performClick() + composeRule.waitForIdle() + + assertEquals(0, primary) + assertEquals(1, secondary) + } + + @Test + fun footer_is_uppercased_when_provided() { + composeRule.setContent { + StackcasinoTheme { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Couldn't load", + message = "msg", + primaryActionLabel = "Retry", + onPrimaryAction = {}, + footer = "Last successful fetch 2h ago", + ) + } + } + + composeRule.onNodeWithText("LAST SUCCESSFUL FETCH 2H AGO").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/FilterChipRowTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/FilterChipRowTest.kt new file mode 100644 index 0000000..bec7c9a --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/ui/components/FilterChipRowTest.kt @@ -0,0 +1,58 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FilterChipRowTest { + @get:Rule + val composeRule = createComposeRule() + + private val chips = + listOf( + FilterChip(key = "all", label = "All"), + FilterChip(key = "wins", label = "Wins"), + FilterChip(key = "losses", label = "Losses"), + ) + + @Test + fun renders_every_chip_label_uppercased() { + composeRule.setContent { + StackcasinoTheme { + FilterChipRow(chips = chips, selected = "all", onSelect = {}) + } + } + + composeRule.onNodeWithText("ALL").assertIsDisplayed() + composeRule.onNodeWithText("WINS").assertIsDisplayed() + composeRule.onNodeWithText("LOSSES").assertIsDisplayed() + } + + @Test + fun clicking_a_chip_invokes_onSelect_with_the_key() { + val selected = mutableStateOf("all") + composeRule.setContent { + StackcasinoTheme { + FilterChipRow( + chips = chips, + selected = selected.value, + onSelect = { selected.value = it }, + ) + } + } + + composeRule.onNodeWithText("WINS").performClick() + composeRule.waitForIdle() + + assertEquals("wins", selected.value) + } +} 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 new file mode 100644 index 0000000..aa6dd92 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/BalancePill.kt @@ -0,0 +1,145 @@ +package com.plainstudio.stackcasino.ui.components + +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.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.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +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.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.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Balance hero block used by the Lobby header and the Wallet header. + * + * Mockup spec (mockup/js/screens/lobby.js, line 40+ and + * mockup/js/screens/wallet.js, line 13+): + * + * 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 (••••••) + * + * The eye toggle is optional. When [onToggleVisibility] is null the + * eye control is hidden and the pill always shows [amount] (used by + * the wallet's locked-balance secondary row). + */ +@Composable +fun BalancePill( + label: String, + amount: String, + modifier: Modifier = Modifier, + isHidden: Boolean = false, + onToggleVisibility: (() -> Unit)? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = label.uppercase(), + color = TextMedium, + fontSize = LabelFontSize, + letterSpacing = LabelLetterSpacing, + ) + Text( + text = if (isHidden) HIDDEN_PLACEHOLDER else amount, + color = TextHigh, + fontSize = AmountFontSize, + fontWeight = FontWeight.Bold, + // tabular-nums keeps the digits monospaced so amounts + // don't shift width when a single digit changes. + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + if (onToggleVisibility != null) { + Box( + modifier = + Modifier + .padding(bottom = 8.dp) + .size(EyeHitArea) + .clickable(onClick = onToggleVisibility), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = + if (isHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (isHidden) "Show balance" else "Hide balance", + tint = TextLow, + modifier = Modifier.size(EyeIconSize), + ) + } + } + } +} + +private const val HIDDEN_PLACEHOLDER = "••••••" + +private val LabelFontSize = 10.sp +private val LabelLetterSpacing = 1.2.sp +private val AmountFontSize = 36.sp +private val EyeIconSize = 18.dp +private val EyeHitArea = 36.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun BalancePillShownPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(20.dp)) { + BalancePill( + label = "Available", + amount = "$1,248.50", + onToggleVisibility = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun BalancePillHiddenPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(20.dp)) { + BalancePill( + label = "Available", + amount = "$1,248.50", + isHidden = true, + onToggleVisibility = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun BalancePillReadOnlyPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + BalancePill(label = "Available", amount = "$1,248.50") + Spacer(modifier = Modifier.height(4.dp)) + BalancePill(label = "Locked", amount = "$0.00") + } + } + } +} 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 new file mode 100644 index 0000000..25ff84e --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/EmptyState.kt @@ -0,0 +1,168 @@ +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 +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.History +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +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 + +/** + * Centered "nothing here yet" state for empty lists and zero-data + * screens (history with no rounds yet, news filtered to nothing, KYC + * intro before any document is uploaded). + * + * Mockup spec (mockup/js/screens/wallet.js, kyc.js, history empty): + * + * icon container: 56dp square, border-line, bg-surface + * icon color: txt-lo + * title: 16sp semibold, txt-hi, mt-4 + * message: 12sp regular, txt-mid, mt-1, line-height 1.25 + * optional CTA: violet button below message + * layout: centered, padded py-12, max width ~280dp + * + * [icon] is a slot so the caller picks the imageVector / drawable + * appropriate to the context. + */ +@Composable +fun EmptyState( + icon: @Composable () -> Unit, + title: String, + message: String, + modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = VerticalPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(IconToTitleGap, Alignment.Top), + ) { + Box( + modifier = + Modifier + .size(IconContainerSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + icon() + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TitleToMessageGap), + modifier = Modifier.widthIn(max = MessageMaxWidth), + ) { + Text( + text = title, + color = TextHigh, + fontSize = TitleFontSize, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = message, + color = TextMedium, + fontSize = MessageFontSize, + lineHeight = MessageLineHeight, + textAlign = TextAlign.Center, + ) + } + 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), + ) + } + } +} + +object EmptyStateDefaults { + @Composable + fun PlaceholderIcon() { + Icon( + imageVector = Icons.Outlined.History, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(IconImageSize), + ) + } +} + +private val IconContainerSize = 56.dp +private val IconImageSize = 28.dp +private val IconToTitleGap = 16.dp +private val TitleToMessageGap = 8.dp +private val VerticalPadding = 48.dp +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 +private fun EmptyStateBasicPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + EmptyState( + icon = { EmptyStateDefaults.PlaceholderIcon() }, + title = "No rounds yet", + message = "Play your first round and it will show up here.", + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun EmptyStateWithActionPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + EmptyState( + icon = { EmptyStateDefaults.PlaceholderIcon() }, + title = "No articles match your search", + message = "Try a different keyword or clear the source filters.", + actionLabel = "Clear filters", + onAction = {}, + ) + } + } +} 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 new file mode 100644 index 0000000..8dac1fb --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/ErrorState.kt @@ -0,0 +1,252 @@ +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.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.WifiOff +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.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.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +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 + +/** + * Centered error card with retry. Used by Lobby ("Connection Lost" + * with RETRY + USE CACHE), News ("Couldn't load articles" with RETRY + * + LAST FETCH timestamp), and Profile ("Couldn't load profile" with + * RETRY). + * + * Mockup spec (mockup/js/screens/{lobby,news,profile}.js): + * + * container: 1px solid danger/50 border, rgba(danger,0.05) bg, p-6 + * icon: 48dp container with danger border, danger tint + * title: 16sp semibold, danger color, mt-4 + * message: 12sp txt-mid, line-height 1.25, mt-2 + * primary: px-4 py-2 violet outline button, tracked uppercase + * secondary: same as primary but transparent border + * footer: text-[9px] tracked txt-lo, separated by border-t mt-4 pt-4 + * + * The [secondaryAction] is optional (lobby uses it for USE CACHE; the + * other screens omit it). The [footer] is optional (news uses it for + * "LAST SUCCESSFUL FETCH 2H AGO"). + */ +@Composable +fun ErrorState( + icon: @Composable () -> Unit, + title: String, + message: String, + primaryActionLabel: String, + onPrimaryAction: () -> Unit, + modifier: Modifier = Modifier, + secondaryActionLabel: String? = null, + onSecondaryAction: (() -> Unit)? = null, + footer: String? = null, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .background(SemanticDanger.copy(alpha = CONTAINER_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticDanger.copy(alpha = CONTAINER_BORDER_ALPHA)) + .padding(ContainerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + Box( + modifier = + Modifier + .size(IconContainerSize) + .background(SemanticDanger.copy(alpha = CONTAINER_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticDanger.copy(alpha = CONTAINER_BORDER_ALPHA)), + contentAlignment = Alignment.Center, + ) { + icon() + } + Text( + text = title, + color = SemanticDanger, + fontSize = TitleFontSize, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = message, + color = TextMedium, + fontSize = MessageFontSize, + lineHeight = MessageLineHeight, + textAlign = TextAlign.Center, + ) + ActionRow( + primaryLabel = primaryActionLabel, + onPrimary = onPrimaryAction, + secondaryLabel = secondaryActionLabel, + onSecondary = onSecondaryAction, + ) + if (footer != null) { + Spacer(modifier = Modifier.height(FooterGap)) + FooterRow(text = footer) + } + } +} + +@Composable +private fun ActionRow( + primaryLabel: String, + onPrimary: () -> Unit, + secondaryLabel: String?, + onSecondary: (() -> Unit)?, +) { + Row(horizontalArrangement = Arrangement.spacedBy(ActionGap)) { + ErrorActionButton(label = primaryLabel, onClick = onPrimary, filled = true) + if (secondaryLabel != null && onSecondary != null) { + ErrorActionButton(label = secondaryLabel, onClick = onSecondary, filled = false) + } + } +} + +@Composable +private fun ErrorActionButton( + label: String, + onClick: () -> Unit, + filled: Boolean, +) { + val background = if (filled) AccentViolet else Color.Transparent + val border = if (filled) AccentViolet else SurfaceOutline + val textColor = if (filled) Color.White else TextHigh + Box( + modifier = + Modifier + .background(background) + .border(width = 1.dp, color = border) + .clickable(onClick = onClick) + .padding(ActionPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label.uppercase(), + color = textColor, + fontSize = ActionFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = ActionLetterSpacing, + ) + } +} + +@Composable +private fun FooterRow(text: String) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) + Spacer(modifier = Modifier.height(FooterGap)) + Text( + text = text.uppercase(), + color = TextLow, + fontSize = FooterFontSize, + letterSpacing = FooterLetterSpacing, + textAlign = TextAlign.Center, + ) + } +} + +object ErrorStateDefaults { + @Composable + fun OfflineIcon() { + Icon( + imageVector = Icons.Outlined.WifiOff, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(IconImageSize), + ) + } +} + +private const val CONTAINER_TINT_ALPHA = 0.05f +private const val CONTAINER_BORDER_ALPHA = 0.5f +private val ContainerPadding = PaddingValues(24.dp) +private val IconContainerSize = 48.dp +private val IconImageSize = 24.dp +private val SectionGap = 12.dp +private val ActionGap = 8.dp +private val ActionPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp) +private val FooterGap = 8.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 FooterFontSize = 9.sp +private val FooterLetterSpacing = 1.2.sp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun ErrorStateBasicPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Couldn't load profile", + message = "Sync with Firestore failed. Pull to retry.", + primaryActionLabel = "Retry", + onPrimaryAction = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun ErrorStateLobbyPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Connection Lost", + message = "Couldn't sync wallet and rounds from Firestore. Showing last known state.", + primaryActionLabel = "Retry", + onPrimaryAction = {}, + secondaryActionLabel = "Use cache", + onSecondaryAction = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun ErrorStateWithFooterPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Couldn't load articles", + message = "NewsAPI is unreachable. Cached articles still available below.", + primaryActionLabel = "Retry", + onPrimaryAction = {}, + footer = "Last successful fetch 2h ago", + ) + } + } +} 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 new file mode 100644 index 0000000..b3b9ef9 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/FilterChipRow.kt @@ -0,0 +1,151 @@ +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.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +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.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.TextMedium + +/** + * Single-select horizontal chip row used by history filters, wallet + * transaction tabs and news source filters. + * + * Mockup spec (mockup/js/screens/{history,wallet,news}.js, + * mockup `.fchip` recipe): + * + * chip: px-3 py-1.5 tracked text-[10px] font-semibold + * active: bg-violet text-white border-violet + * inactive: text-txt-mid border-line, hover:border-violet + * gap: 8dp between chips + * + * Generic over the chip key type so callers stay type-safe (an enum, + * sealed type, or domain id works equally well). [scrollable] flips + * the layout from a wrapped Row to a horizontal-scroll lane (news + * source filters use this; wallet tabs do not). + */ +@Composable +fun FilterChipRow( + chips: List>, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + scrollable: Boolean = false, +) { + val container = + if (scrollable) { + modifier.horizontalScroll(rememberScrollState()) + } else { + modifier + } + Row( + modifier = container, + horizontalArrangement = Arrangement.spacedBy(ChipGap), + verticalAlignment = Alignment.CenterVertically, + ) { + chips.forEach { chip -> + Chip( + label = chip.label, + isActive = chip.key == selected, + onClick = { onSelect(chip.key) }, + ) + } + } +} + +data class FilterChip( + val key: T, + val label: String, +) + +@Composable +private fun Chip( + label: String, + isActive: Boolean, + onClick: () -> Unit, +) { + val backgroundColor = if (isActive) AccentViolet else Color.Transparent + val borderColor = if (isActive) AccentViolet else SurfaceOutline + val textColor = if (isActive) Color.White else TextMedium + Box( + modifier = + Modifier + .background(backgroundColor) + .border(width = 1.dp, color = borderColor) + .clickable(onClick = onClick) + .padding(horizontal = ChipPaddingHorizontal, vertical = ChipPaddingVertical), + ) { + Text( + text = label.uppercase(), + color = textColor, + fontSize = ChipFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = ChipLetterSpacing, + ) + } +} + +private val ChipGap = 8.dp +private val ChipPaddingHorizontal = 12.dp +private val ChipPaddingVertical = 6.dp +private val ChipFontSize = 10.sp +private val ChipLetterSpacing = 1.2.sp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun FilterChipRowPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + val chips = + listOf( + FilterChip(key = "all", label = "All"), + FilterChip(key = "wins", label = "Wins"), + FilterChip(key = "losses", label = "Losses"), + ) + FilterChipRow( + chips = chips, + selected = "wins", + onSelect = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun FilterChipRowScrollablePreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + val chips = + listOf( + FilterChip(key = "all", label = "All sources"), + FilterChip(key = "crypto", label = "CryptoNews"), + FilterChip(key = "defi", label = "Defi Daily"), + FilterChip(key = "polygon", label = "Polygon Post"), + FilterChip(key = "block", label = "Blockchain News"), + ) + FilterChipRow( + chips = chips, + selected = "all", + onSelect = {}, + scrollable = true, + ) + } + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/Skeleton.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/Skeleton.kt new file mode 100644 index 0000000..fbb32aa --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/Skeleton.kt @@ -0,0 +1,105 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceElevated + +/** + * Loading placeholder primitive. The caller decides shape via the + * passed [Modifier] (`.size(...)`, `.fillMaxWidth().height(...)`, + * `.fillMaxWidth().aspectRatio(...)` etc.), which matches the + * mockup's inline `animate-pulse` blocks where every screen sizes the + * placeholder to the slot it occupies. + * + * Ports Tailwind `animate-pulse` from mockup/styles.css: + * + * @keyframes pulse { + * 0%, 100% { opacity: 1; } + * 50% { opacity: 0.5; } + * } + * animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite + * + * The background tone is SurfaceElevated so the placeholder reads as + * a raised block on the SurfaceBase / SurfaceRaised parents the + * screens use. + */ +@Composable +fun Skeleton(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "skeleton-pulse") + val alpha by transition.animateFloat( + initialValue = PULSE_ALPHA_MAX, + targetValue = PULSE_ALPHA_MIN, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = PULSE_DURATION_MILLIS, easing = PulseEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton-alpha", + ) + Box( + modifier = + modifier + .alpha(alpha) + .background(SurfaceElevated), + ) +} + +private const val PULSE_ALPHA_MIN = 0.5f +private const val PULSE_ALPHA_MAX = 1f +private const val PULSE_DURATION_MILLIS = 1000 + +// Mirrors Tailwind's `animate-pulse` cubic-bezier(0.4, 0, 0.6, 1). +private val PulseEasing = CubicBezierEasing(0.4f, 0f, 0.6f, 1f) + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun SkeletonShapesPreview() { + StackcasinoTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Lines (e.g. text placeholders in lobby/news loading). + Skeleton(modifier = Modifier.width(96.dp).height(8.dp)) + Skeleton(modifier = Modifier.width(224.dp).height(8.dp)) + Skeleton(modifier = Modifier.width(112.dp).height(8.dp)) + + // Block (e.g. balance hero, news featured image). + Skeleton(modifier = Modifier.fillMaxWidth().height(40.dp)) + + // Avatar circle equivalent (square because the theme bans + // border radius globally). + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Skeleton(modifier = Modifier.size(64.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Skeleton(modifier = Modifier.fillMaxWidth(0.6f).height(16.dp)) + Skeleton(modifier = Modifier.fillMaxWidth(0.4f).height(8.dp)) + } + } + } + } +} 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 new file mode 100644 index 0000000..2512ac7 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackCard.kt @@ -0,0 +1,129 @@ +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.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 +import androidx.compose.foundation.layout.width +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh + +/** + * Generic bordered container used across the app's primary screens. + * + * 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 + * `* { border-radius:0 !important }` globally. + * + * Three orthogonal variations capture every observed usage: + * + * * [leftAccent] paints a 3dp left strip in the given color. Used + * by history round items and wallet transactions to encode + * win/loss / deposit/withdrawal in a single glance. + * * [elevated] swaps the surface tone (SurfaceRaised -> SurfaceElevated) + * to match the mockup `bg-elev` variant. + * * [onClick] enables ripple feedback when the card is interactive. + * + * Content padding defaults to 16dp (the mockup's `p-4`). Callers can + * override via [contentPadding] for the rare `p-5` variant. + */ +@Composable +fun StackCard( + modifier: Modifier = Modifier, + leftAccent: Color? = null, + elevated: Boolean = false, + onClick: (() -> Unit)? = null, + contentPadding: PaddingValues = StackCardDefaults.contentPadding, + content: @Composable () -> Unit, +) { + val surfaceColor = if (elevated) SurfaceElevated else SurfaceRaised + val clickModifier = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + Row( + modifier = + modifier + .background(surfaceColor) + .border(width = 1.dp, color = SurfaceOutline) + .then(clickModifier), + ) { + if (leftAccent != null) { + Box( + modifier = + Modifier + .width(StackCardDefaults.accentWidth) + .fillMaxHeight() + .background(leftAccent), + ) + } + Box(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } +} + +object StackCardDefaults { + val contentPadding: PaddingValues = PaddingValues(16.dp) + val accentWidth = 3.dp +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun StackCardDefaultPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + StackCard(modifier = Modifier.fillMaxWidth()) { + Text(text = "Plain card", color = TextHigh) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun StackCardWithAccentsPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + Spacer(modifier = Modifier.padding(top = 0.dp)) + StackCard( + modifier = Modifier.fillMaxWidth(), + leftAccent = SemanticOk, + onClick = {}, + ) { + Text(text = "Win round (clickable)", color = TextHigh) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun StackCardElevatedDangerPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + StackCard( + modifier = Modifier.fillMaxWidth(), + leftAccent = SemanticDanger, + elevated = true, + ) { + Text(text = "Loss round (elevated)", color = TextHigh) + } + } + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 1a1f78c..6e79957 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -37,6 +37,9 @@ style: ignoreAnnotation: true ignoreEnums: true ignoreNumbers: ['-1', '0', '1', '2'] + # @Preview composables are throwaway demo wiring; fractional widths + # and sample padding there are not load-bearing constants. + ignoreAnnotated: ['Preview'] MaxLineLength: maxLineLength: 140 WildcardImport: