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: