diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/wallet/WalletScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/wallet/WalletScreenTest.kt new file mode 100644 index 0000000..bfcaf19 --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/wallet/WalletScreenTest.kt @@ -0,0 +1,93 @@ +package com.plainstudio.stackcasino.feature.wallet + +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.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.plainstudio.stackcasino.navigation.Route +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 WalletScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun deposit_tab_renders_address_and_warning() { + composeRule.setContent { + StackcasinoTheme { + WalletScreen(data = previewWalletData(), onNavigate = {}) + } + } + + composeRule.onNodeWithText("Wallet").assertIsDisplayed() + composeRule.onNodeWithText("YOUR DEPOSIT ADDRESS").performScrollTo().assertIsDisplayed() + composeRule + .onNodeWithText(previewWalletData().depositAddress) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun switching_to_withdraw_renders_form_and_kyc_gate() { + composeRule.setContent { + StackcasinoTheme { + WalletScreen(data = previewWalletData(), onNavigate = {}) + } + } + + composeRule.onNodeWithText("WITHDRAW").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("DESTINATION ADDRESS").performScrollTo().assertIsDisplayed() + composeRule + .onNodeWithText("KYC required for withdrawals over $100") + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun withdraw_kyc_cta_navigates_to_kyc_route() { + var navigated: Route? = null + composeRule.setContent { + StackcasinoTheme { + WalletScreen( + data = previewWalletData(), + onNavigate = { navigated = it }, + ) + } + } + + composeRule.onNodeWithText("WITHDRAW").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText("VERIFY IDENTITY").performScrollTo().performClick() + composeRule.waitForIdle() + + assertEquals(Route.Kyc, navigated) + } + + @Test + fun transactions_tab_lists_rounds_and_filters() { + composeRule.setContent { + StackcasinoTheme { + WalletScreen(data = previewWalletData(), onNavigate = {}) + } + } + + composeRule.onNodeWithText("TRANSACTIONS").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("+$500.00").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("-$50.00").performScrollTo().assertIsDisplayed() + + composeRule.onNodeWithText("DEPOSITS").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText("+$500.00").performScrollTo().assertIsDisplayed() + } +} 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 d617d58..983b20a 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 @@ -19,19 +19,15 @@ 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.filled.KeyboardArrowDown 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.DropdownMenu -import androidx.compose.material3.DropdownMenuItem 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 -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -48,6 +44,7 @@ import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.R 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 @@ -59,7 +56,6 @@ 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.SurfaceElevated import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.SurfaceRaised import com.plainstudio.stackcasino.ui.theme.TextHigh @@ -363,74 +359,6 @@ private fun PnLChip( } } -/** - * Currency picker. Tapping toggles a DropdownMenu listing USDC + USDT; - * picking an option updates the visible code. Network label stays put - * because both currencies live on Polygon in the mockup. - */ -@Composable -private fun CurrencyDropdown( - initialCurrency: String, - networkLabel: String, -) { - var expanded by remember { mutableStateOf(false) } - var selected by rememberSaveable { mutableStateOf(initialCurrency) } - Box { - Row( - modifier = Modifier.clickable { expanded = true }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "$selected · $networkLabel", - color = TextLow, - fontSize = MetaFontSize, - letterSpacing = TrackedLetterSpacing, - ) - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = null, - tint = TextLow, - modifier = Modifier.size(CurrencyChevronSize), - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(SurfaceElevated), - ) { - CurrencyOptions.forEach { code -> - DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier - .size(CurrencyDotSize) - .background(if (code == selected) AccentViolet else Color.Transparent), - ) - Text( - text = code, - color = if (code == selected) AccentViolet else TextHigh, - fontSize = MetaFontSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = TrackedLetterSpacing, - ) - } - }, - onClick = { - selected = code - expanded = false - }, - ) - } - } - } -} - @Composable private fun LockedColumn(subtitle: String) { Column(horizontalAlignment = Alignment.End) { @@ -1086,10 +1014,6 @@ private val RecentSkeletonHeight = 56.dp private const val RECENT_SKELETON_COUNT = 3 private val ViewAllChevronSize = 12.dp -private val CurrencyChevronSize = 10.dp -private val CurrencyDotSize = 6.dp - -private val CurrencyOptions = listOf("USDC", "USDT") private val NepFabSize = 48.dp private val NepFabBorderWidth = 2.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletDepositTab.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletDepositTab.kt new file mode 100644 index 0000000..08a9d2f --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletDepositTab.kt @@ -0,0 +1,367 @@ +package com.plainstudio.stackcasino.feature.wallet + +import androidx.compose.foundation.Canvas +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.ContentCopy +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.WarningAmber +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +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.AccentVioletSoft +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SemanticWarn +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Deposit tab. Renders the mock QR card, the deposit address row with + * copy / share actions, the Polygon network chip and the lossy-network + * warning. Copy / share are visual stubs; real clipboard + share + * intents ship with the wallet repository in a later card. + */ +@Composable +internal fun WalletDepositTab(address: String) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + DepositCard(address = address) + DepositWarning() + } +} + +@Composable +private fun DepositCard(address: String) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .padding(DepositCardPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCodeMock() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "YOUR DEPOSIT ADDRESS", + color = TextMedium, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(12.dp)) + AddressRow(address = address) + Spacer(modifier = Modifier.height(16.dp)) + NetworkChip() + } +} + +/** + * Mock QR. The mockup draws a dot grid plus three finder squares and a + * STACK glyph in the centre. The drawing is decorative; the actual + * address lives in [AddressRow] right below it. + */ +@Composable +private fun QrCodeMock() { + Box( + modifier = + Modifier + .size(QrSize) + .background(SurfaceElevated) + .padding(QrInnerPadding), + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val step = QR_DOT_STEP_PX + val radius = QR_DOT_RADIUS_PX + var y = step / 2f + while (y < size.height) { + var x = step / 2f + while (x < size.width) { + drawCircle( + color = AccentViolet, + radius = radius, + center = Offset(x, y), + ) + x += step + } + y += step + } + } + QrFinderSquare(modifier = Modifier.align(Alignment.TopStart)) + QrFinderSquare(modifier = Modifier.align(Alignment.TopEnd)) + QrFinderSquare(modifier = Modifier.align(Alignment.BottomStart)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + QrCenterGlyph() + } + } +} + +@Composable +private fun QrFinderSquare(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .size(QrFinderSize) + .background(SurfaceElevated) + .border(width = QrFinderBorderWidth, color = AccentViolet), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(QrFinderDotSize) + .background(AccentViolet), + ) + } +} + +@Composable +private fun QrCenterGlyph() { + Box( + modifier = + Modifier + .size(QrGlyphOuterSize) + .background(SurfaceElevated) + .padding(QrGlyphInnerPadding), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .border(width = QrGlyphOuterBorder, color = AccentViolet), + ) + Box( + modifier = + Modifier + .fillMaxSize() + .padding(QrGlyphMiddleInset) + .border(width = QrGlyphMiddleBorder, color = AccentVioletSoft), + ) + Box( + modifier = + Modifier + .fillMaxSize() + .padding(QrGlyphCoreInset) + .background(AccentViolet), + ) + } +} + +@Composable +private fun AddressRow(address: String) { + // height(IntrinsicSize.Max) pins the row height to its tallest + // intrinsic child (the wrapped address), letting the action + // buttons' fillMaxHeight actually resolve. Without it, Row defers + // to wrap-content and fillMaxHeight collapses to 0. + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .border(width = 1.dp, color = SurfaceOutline) + .background(SurfaceBase), + ) { + Text( + text = address, + color = TextHigh, + fontSize = AddressFontSize, + fontFamily = FontFamily.Monospace, + lineHeight = AddressLineHeight, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) + AddressActionButton( + icon = Icons.Outlined.IosShare, + contentDescription = "Share address", + background = SurfaceElevated, + tint = TextMedium, + onClick = { /* share intent ships with wallet repository */ }, + ) + AddressActionButton( + icon = Icons.Outlined.ContentCopy, + contentDescription = "Copy address", + background = AccentViolet, + tint = Color.White, + onClick = { /* clipboard write ships with wallet repository */ }, + ) + } +} + +@Composable +private fun AddressActionButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + background: Color, + tint: Color, + onClick: () -> Unit, +) { + // fillMaxHeight + aspectRatio(1f) keeps each button square at + // whatever height the wrapped mono address pushes the row to, + // instead of being a fixed 44dp width that ends up rectangular. + Box( + modifier = + Modifier + .fillMaxHeight() + .aspectRatio(1f) + .background(background) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(AddressButtonIconSize), + ) + } +} + +@Composable +private fun NetworkChip() { + Row( + modifier = + Modifier + .background(SurfaceElevated) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(NetworkDotSize) + .background(SemanticOk), + ) + Text( + text = "POLYGON · USDC/USDT", + color = TextHigh, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun DepositWarning() { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SemanticWarn.copy(alpha = WARNING_BG_ALPHA)) + .border(width = 1.dp, color = SemanticWarn.copy(alpha = WARNING_BORDER_ALPHA)) + .padding(WarningPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = SemanticWarn, + modifier = + Modifier + .size(WarningIconSize) + .padding(top = 2.dp), + ) + Text( + text = depositWarningMessage(), + color = TextMedium, + fontSize = WarningTextSize, + lineHeight = WarningTextLineHeight, + textAlign = TextAlign.Start, + ) + } +} + +private fun depositWarningMessage(): AnnotatedString = + buildAnnotatedString { + append("Send only ") + withStyle(SpanStyle(color = Color.White, fontWeight = FontWeight.SemiBold)) { append("USDC") } + append(" or ") + withStyle(SpanStyle(color = Color.White, fontWeight = FontWeight.SemiBold)) { append("USDT") } + append( + " on the Polygon network to this address. Other tokens or " + + "networks may result in loss of funds.", + ) + } + +// --------------------------------------------------------------------------- +// Tokens +// --------------------------------------------------------------------------- + +private val DepositCardPadding = PaddingValues(20.dp) + +// Mock QR sizing. Hardcoded to a 192dp box so the dot grid + finder +// squares + glyph all fit the proportions the mockup uses. +private val QrSize = 192.dp +private val QrInnerPadding = PaddingValues(16.dp) +private const val QR_DOT_STEP_PX = 14f +private const val QR_DOT_RADIUS_PX = 2.8f +private val QrFinderSize = 36.dp +private val QrFinderBorderWidth = 2.dp +private val QrFinderDotSize = 12.dp +private val QrGlyphOuterSize = 48.dp +private val QrGlyphInnerPadding = PaddingValues(4.dp) +private val QrGlyphOuterBorder = 2.dp +private val QrGlyphMiddleBorder = 1.dp +private val QrGlyphMiddleInset = 6.dp +private val QrGlyphCoreInset = 10.dp + +private val AddressFontSize = 11.sp +private val AddressLineHeight = 14.sp +private val AddressButtonIconSize = 16.dp + +private val NetworkDotSize = 6.dp + +private val WarningPadding = PaddingValues(16.dp) +private val WarningIconSize = 18.dp +private val WarningTextSize = 12.sp +private val WarningTextLineHeight = 18.sp +private const val WARNING_BG_ALPHA = 0.05f +private const val WARNING_BORDER_ALPHA = 0.40f + +private val LabelFontSize = 10.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletPreviewData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletPreviewData.kt new file mode 100644 index 0000000..a8f5058 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletPreviewData.kt @@ -0,0 +1,53 @@ +package com.plainstudio.stackcasino.feature.wallet + +/** + * Static seed used by the wallet @Preview composables and the live + * navigation entry until a Firestore-backed VM ships. Numbers and + * labels mirror mockup/js/screens/wallet.js so the rendered screen + * matches the design source one-for-one. + */ +internal fun previewWalletData(): WalletData = + WalletData( + availableLabel = "$1,234.56", + lockedLabel = "$0.00", + currencyCode = "USDC", + networkLabel = "Polygon", + depositAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + transactions = previewTransactions(), + ) + +private fun previewTransactions(): List = + listOf( + WalletTransaction( + type = TransactionType.Deposit, + status = TransactionStatus.Confirmed, + timestampLabel = "4/16/2026 · 7:30 AM", + shortHash = "0x3f9a…c7e2", + amountLabel = "+$500.00", + currencyCode = "USDC", + ), + WalletTransaction( + type = TransactionType.Withdraw, + status = TransactionStatus.Pending, + timestampLabel = "4/15/2026 · 11:20 AM", + shortHash = "0x8b2d…a1f0", + amountLabel = "-$120.00", + currencyCode = "USDC", + ), + WalletTransaction( + type = TransactionType.Deposit, + status = TransactionStatus.Confirmed, + timestampLabel = "4/14/2026 · 6:15 AM", + shortHash = "0xd41c…5e89", + amountLabel = "+$200.00", + currencyCode = "USDC", + ), + WalletTransaction( + type = TransactionType.Withdraw, + status = TransactionStatus.Failed, + timestampLabel = "4/13/2026 · 1:45 PM", + shortHash = "0x17ef…b304", + amountLabel = "-$50.00", + currencyCode = "USDC", + ), + ) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt new file mode 100644 index 0000000..edd3d48 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletScreen.kt @@ -0,0 +1,263 @@ +package com.plainstudio.stackcasino.feature.wallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 +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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +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.navigation.Route +import com.plainstudio.stackcasino.ui.components.CurrencyDropdown +import com.plainstudio.stackcasino.ui.components.gridBackground +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Wallet screen reproducing the cu-04 mockup + * (mockup/js/screens/wallet.js). Owns the screen-level scaffold: + * + * * Title header. + * * Balance hero (mirrors Lobby's, minus the eye toggle). + * * Tab strip (Deposit / Withdraw / Transactions). + * * Tab pane, dispatched on the currently selected [WalletTab]. + * + * Pane content lives in dedicated files + * (WalletDepositTab / WalletWithdrawTab / WalletTransactionsTab) so + * each section stays focused and the detekt function-count budget + * is respected. + */ +@Composable +fun WalletScreen( + data: WalletData, + onNavigate: (Route) -> Unit, + modifier: Modifier = Modifier, +) { + var selectedTab by rememberSaveable { mutableStateOf(WalletTab.Deposit) } + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column( + modifier = + Modifier + .fillMaxSize() + .gridBackground() + .verticalScroll(rememberScrollState()), + ) { + WalletHeader() + HorizontalDivider() + BalanceBlock(data = data) + HorizontalDivider() + WalletTabStrip(selected = selectedTab, onSelect = { selectedTab = it }) + when (selectedTab) { + WalletTab.Deposit -> WalletDepositTab(address = data.depositAddress) + WalletTab.Withdraw -> + WalletWithdrawTab( + availableLabel = data.availableLabel, + currencyCode = data.currencyCode, + onVerifyIdentity = { onNavigate(Route.Kyc) }, + ) + WalletTab.Transactions -> + WalletTransactionsTab( + transactions = data.transactions, + onGoToDeposit = { selectedTab = WalletTab.Deposit }, + ) + } + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } + } +} + +@Composable +private fun WalletHeader() { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = HeaderTopPadding, + bottom = HeaderBottomPadding, + ), + ) { + Text( + text = "Wallet", + color = TextHigh, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun BalanceBlock(data: WalletData) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Column { + Text( + text = "AVAILABLE", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = data.availableLabel, + color = TextHigh, + fontSize = AmountFontSize, + fontWeight = FontWeight.Bold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + Spacer(modifier = Modifier.height(4.dp)) + CurrencyDropdown( + initialCurrency = data.currencyCode, + networkLabel = data.networkLabel, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "LOCKED", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = data.lockedLabel, + color = TextMedium, + fontSize = LockedAmountFontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + } +} + +@Composable +private fun WalletTabStrip( + selected: WalletTab, + onSelect: (WalletTab) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(TabGap), + ) { + WalletTab.entries.forEach { tab -> + WalletTabButton( + label = tab.label, + isSelected = tab == selected, + onClick = { onSelect(tab) }, + ) + } + } + HorizontalDivider() +} + +@Composable +private fun WalletTabButton( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val textColor = if (isSelected) AccentViolet else TextMedium + // drawBehind paints the underline directly under the text without + // wrapping the Text in a Column. Wrapping with a fillMaxWidth Box + // forces the parent Row to give the whole row to the first tab, + // collapsing the other two to a single character of width. + Text( + text = label.uppercase(), + color = textColor, + fontSize = TabFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + modifier = + Modifier + .clickable(onClick = onClick) + .padding(vertical = TabVerticalPadding) + .drawBehind { + if (!isSelected) return@drawBehind + val strokePx = TabIndicatorHeight.toPx() + drawRect( + color = AccentViolet, + topLeft = Offset(0f, size.height - strokePx), + size = Size(size.width, strokePx), + ) + }, + ) +} + +@Composable +internal fun HorizontalDivider() { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(SurfaceOutline), + ) +} + +// --------------------------------------------------------------------------- +// Tokens shared with the tab files via the `internal` modifier. +// --------------------------------------------------------------------------- + +internal val ScreenHorizontalPadding = 16.dp +internal val SectionVerticalPadding = 20.dp +internal val SectionGap = 16.dp + +private val HeaderTopPadding = 24.dp +private val HeaderBottomPadding = 16.dp +private val BottomScrollPadding = 96.dp + +private val TabGap = 24.dp +private val TabVerticalPadding = 12.dp +private val TabFontSize = 11.sp +private val TabIndicatorHeight = 2.dp + +private val AmountFontSize = 36.sp +private val LockedAmountFontSize = 18.sp +private val MetaFontSize = 10.sp +internal val TrackedLetterSpacing = 1.2.sp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200) +@Composable +private fun WalletScreenDepositPreview() { + StackcasinoTheme { + WalletScreen(data = previewWalletData(), onNavigate = {}) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletTransactionsTab.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletTransactionsTab.kt new file mode 100644 index 0000000..8c7f1a4 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletTransactionsTab.kt @@ -0,0 +1,253 @@ +package com.plainstudio.stackcasino.feature.wallet + +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.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Receipt +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.components.EmptyState +import com.plainstudio.stackcasino.ui.components.FilterChip +import com.plainstudio.stackcasino.ui.components.FilterChipRow +import com.plainstudio.stackcasino.ui.components.StackCard +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.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Transactions tab. Renders a FilterChipRow plus the filtered ledger. + * When the active filter matches no rows, an EmptyState card with a + * "Deposit Now" CTA pushes the user back to the Deposit tab. + */ +@Composable +internal fun WalletTransactionsTab( + transactions: List, + onGoToDeposit: () -> Unit, +) { + var filter by rememberSaveable { mutableStateOf(TransactionFilter.All) } + val visible = transactions.filter { it.matches(filter) } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + ) { + FilterChipRow( + chips = filterChips(), + selected = filter, + onSelect = { filter = it }, + ) + Spacer(modifier = Modifier.height(12.dp)) + if (visible.isEmpty()) { + EmptyTransactionsState(onGoToDeposit = onGoToDeposit) + } else { + TransactionList(visible) + } + } +} + +@Composable +private fun TransactionList(transactions: List) { + Column(verticalArrangement = Arrangement.spacedBy(RowGap)) { + transactions.forEach { tx -> TransactionRow(tx) } + } +} + +@Composable +private fun TransactionRow(tx: WalletTransaction) { + StackCard( + modifier = Modifier.fillMaxWidth(), + leftAccent = tx.accentColor(), + contentPadding = PaddingValues(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + TransactionPrimaryColumn(tx) + TransactionAmountColumn(tx) + } + } +} + +@Composable +private fun TransactionPrimaryColumn(tx: WalletTransaction) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = tx.icon(), + contentDescription = null, + tint = tx.iconTint(), + modifier = Modifier.size(RowIconSize), + ) + Text( + text = tx.type.label(), + color = TextHigh, + fontSize = RowTitleFontSize, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = tx.timestampLabel, + color = TextLow, + fontSize = RowMetaFontSize, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + HashRow(shortHash = tx.shortHash) + } +} + +@Composable +private fun HashRow(shortHash: String) { + Row( + modifier = Modifier.clickable { /* explorer deep-link ships with wallet repo */ }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = shortHash, + color = TextLow, + fontSize = HashFontSize, + fontFamily = FontFamily.Monospace, + ) + Icon( + imageVector = Icons.Outlined.OpenInNew, + contentDescription = "Open in block explorer", + tint = TextLow, + modifier = Modifier.size(HashIconSize), + ) + } +} + +@Composable +private fun TransactionAmountColumn(tx: WalletTransaction) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = tx.amountLabel, + color = tx.amountColor(), + fontSize = AmountFontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + Text( + text = tx.currencyCode, + color = TextLow, + fontSize = CurrencyFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun EmptyTransactionsState(onGoToDeposit: () -> Unit) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + EmptyState( + icon = { + Icon( + imageVector = Icons.Outlined.Receipt, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(EmptyIconSize), + ) + }, + title = "No transactions yet", + message = "Deposit crypto to get started.", + actionLabel = "Deposit Now", + onAction = onGoToDeposit, + ) + } +} + +private fun WalletTransaction.matches(filter: TransactionFilter): Boolean = + when (filter) { + TransactionFilter.All -> true + TransactionFilter.Deposits -> type == TransactionType.Deposit + TransactionFilter.Withdrawals -> type == TransactionType.Withdraw + } + +private fun WalletTransaction.accentColor(): Color = + when (status) { + TransactionStatus.Confirmed -> + if (type == TransactionType.Deposit) SemanticOk else TextMedium + TransactionStatus.Pending -> SemanticWarn + TransactionStatus.Failed -> SemanticDanger + } + +private fun WalletTransaction.amountColor(): Color = + when { + type == TransactionType.Deposit && status == TransactionStatus.Confirmed -> SemanticOk + status == TransactionStatus.Failed -> SemanticDanger + else -> TextHigh + } + +private fun WalletTransaction.icon() = + when (type) { + TransactionType.Deposit -> Icons.Outlined.ArrowDownward + TransactionType.Withdraw -> Icons.Outlined.ArrowUpward + } + +private fun WalletTransaction.iconTint(): Color = + when (status) { + TransactionStatus.Confirmed -> + if (type == TransactionType.Deposit) SemanticOk else TextMedium + TransactionStatus.Pending -> SemanticWarn + TransactionStatus.Failed -> SemanticDanger + } + +private fun TransactionType.label(): String = + when (this) { + TransactionType.Deposit -> "Deposit" + TransactionType.Withdraw -> "Withdraw" + } + +private fun filterChips(): List> = + TransactionFilter.entries.map { FilterChip(key = it, label = it.label) } + +// --------------------------------------------------------------------------- +// Tokens +// --------------------------------------------------------------------------- + +private val RowGap = 8.dp +private val RowIconSize = 14.dp +private val RowTitleFontSize = 15.sp +private val RowMetaFontSize = 11.sp +private val HashFontSize = 10.sp +private val HashIconSize = 10.dp +private val AmountFontSize = 15.sp +private val CurrencyFontSize = 9.sp +private val EmptyIconSize = 24.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletUiState.kt new file mode 100644 index 0000000..9cbb345 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletUiState.kt @@ -0,0 +1,62 @@ +package com.plainstudio.stackcasino.feature.wallet + +/** + * Aggregate snapshot the wallet screen renders. The screen has no + * Firestore-backed ViewModel yet, so a static [WalletPreviewData] + * seeds both the @Preview composables and the live nav-host entry. + */ +data class WalletData( + val availableLabel: String, + val lockedLabel: String, + val currencyCode: String, + val networkLabel: String, + val depositAddress: String, + val transactions: List, +) + +/** + * Tab segments rendered by the wallet header strip. Order in the enum + * matches the visual order in the mockup (deposit first / default). + */ +enum class WalletTab( + val label: String, +) { + Deposit("Deposit"), + Withdraw("Withdraw"), + Transactions("Transactions"), +} + +/** + * Transaction list filter scope. `All` is the default; the other two + * mirror the `data-filter` chips in the mockup. + */ +enum class TransactionFilter( + val label: String, +) { + All("All"), + Deposits("Deposits"), + Withdrawals("Withdrawals"), +} + +/** + * A single ledger entry. [amountLabel] is pre-signed ("+$500.00") and + * the colour of the label is decided by [type] + [status] (a confirmed + * withdraw and a failed withdraw differ visually). + */ +data class WalletTransaction( + val type: TransactionType, + val status: TransactionStatus, + val timestampLabel: String, + val shortHash: String, + val amountLabel: String, + val currencyCode: String, +) + +enum class TransactionType { Deposit, Withdraw } + +/** + * Tri-state lifecycle for a transaction. [Confirmed] paints the row + * with the matching outcome accent (ok / txt-mid), [Pending] keeps the + * warn-amber stripe from the mockup, [Failed] uses danger-red. + */ +enum class TransactionStatus { Confirmed, Pending, Failed } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletWithdrawTab.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletWithdrawTab.kt new file mode 100644 index 0000000..4a5e644 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/wallet/WalletWithdrawTab.kt @@ -0,0 +1,472 @@ +package com.plainstudio.stackcasino.feature.wallet + +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.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +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.SemanticWarn +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 + +/** + * Withdraw tab. Renders the destination + amount form, the inline + * available-balance hint, the network-fee summary, the KYC gate and + * the (disabled) Withdraw button. + * + * Validation, signing and submission are owned by the wallet + * repository in a later card; this composable only models visual + * state. The Max button still snaps the amount to [availableLabel] + * so the form behaviour is testable. + */ +@Composable +internal fun WalletWithdrawTab( + availableLabel: String, + currencyCode: String, + onVerifyIdentity: () -> Unit, +) { + var address by rememberSaveable { mutableStateOf("") } + var amount by rememberSaveable { mutableStateOf("") } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + WithdrawForm( + address = address, + onAddressChange = { address = it }, + amount = amount, + onAmountChange = { amount = it }, + availableLabel = availableLabel, + currencyCode = currencyCode, + onMaxClick = { amount = availableLabel.removePrefix("$") }, + ) + KycGate(onVerifyIdentity = onVerifyIdentity) + WithdrawButton(enabled = false, onClick = {}) + } +} + +@Composable +private fun WithdrawForm( + address: String, + onAddressChange: (String) -> Unit, + amount: String, + onAmountChange: (String) -> Unit, + availableLabel: String, + currencyCode: String, + onMaxClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .padding(FormPadding), + verticalArrangement = Arrangement.spacedBy(FieldGap), + ) { + AddressField(value = address, onValueChange = onAddressChange) + AmountField( + amount = amount, + onAmountChange = onAmountChange, + availableLabel = availableLabel, + currencyCode = currencyCode, + onMaxClick = onMaxClick, + ) + FeeSummary() + } +} + +@Composable +private fun AddressField( + value: String, + onValueChange: (String) -> Unit, +) { + Column { + FieldLabel(text = "Destination Address") + Spacer(modifier = Modifier.height(8.dp)) + WithdrawTextField( + value = value, + onValueChange = onValueChange, + placeholder = "0x…", + fontFamily = FontFamily.Monospace, + keyboardType = KeyboardType.Ascii, + ) + } +} + +@Composable +private fun AmountField( + amount: String, + onAmountChange: (String) -> Unit, + availableLabel: String, + currencyCode: String, + onMaxClick: () -> Unit, +) { + Column { + FieldLabel(text = "Amount ($currencyCode)") + Spacer(modifier = Modifier.height(8.dp)) + AmountChipsRow(onPresetClick = onAmountChange, onMaxClick = onMaxClick) + Spacer(modifier = Modifier.height(8.dp)) + WithdrawTextField( + value = amount, + onValueChange = onAmountChange, + placeholder = "0.00", + fontFamily = FontFamily.Default, + keyboardType = KeyboardType.Decimal, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "AVAILABLE: $availableLabel $currencyCode", + color = TextLow, + fontSize = SmallLabelFontSize, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun AmountChipsRow( + onPresetClick: (String) -> Unit, + onMaxClick: () -> Unit, +) { + // All five chips share the row equally (mockup `flex-1`); the violet + // border on MAX is what differentiates it visually. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(AmountChipGap), + ) { + AMOUNT_PRESETS.forEach { preset -> + AmountChip( + label = "$$preset", + onClick = { onPresetClick(preset) }, + modifier = Modifier.weight(1f), + ) + } + MaxAmountChip(onClick = onMaxClick, modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun AmountChip( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick) + .padding(vertical = AmountChipVerticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = TextMedium, + fontSize = AmountChipFontSize, + lineHeight = AmountChipFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun MaxAmountChip( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .background(SurfaceBase) + .border(width = 1.dp, color = AccentViolet) + .clickable(onClick = onClick) + .padding(vertical = AmountChipVerticalPadding), + contentAlignment = Alignment.Center, + ) { + // Mirror AmountChip exactly so MAX has the same intrinsic + // height; the only visible difference must come from the + // violet border + text colour. Without an explicit lineHeight, + // this Text falls back to the theme's body lineHeight and the + // chip ends up visibly taller than its dollar-amount siblings. + Text( + text = "MAX", + color = AccentViolet, + fontSize = AmountChipFontSize, + lineHeight = AmountChipFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun WithdrawTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + fontFamily: FontFamily, + keyboardType: KeyboardType, +) { + val textStyle = + TextStyle( + color = TextHigh, + fontSize = FieldFontSize, + fontFamily = fontFamily, + fontFeatureSettings = if (keyboardType == KeyboardType.Decimal) "tnum" else "", + ) + Box( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = textStyle, + cursorBrush = SolidColor(AccentViolet), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + modifier = Modifier.fillMaxWidth(), + ) + if (value.isEmpty()) { + Text( + text = placeholder, + color = TextLow, + fontSize = FieldFontSize, + fontFamily = fontFamily, + ) + } + } +} + +@Composable +private fun FeeSummary() { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(SurfaceOutline), + ) + Spacer(modifier = Modifier.height(2.dp)) + FeeRow(label = "Network Fee", amount = "$0.50") + FeeReceiveRow() + } +} + +@Composable +private fun FeeRow( + label: String, + amount: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + color = TextMedium, + fontSize = FeeFontSize, + ) + Text( + text = amount, + color = TextHigh, + fontSize = FeeFontSize, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun FeeReceiveRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "You'll Receive", + color = TextHigh, + fontSize = FeeFontSize, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "$0.00", + color = AccentViolet, + fontSize = FeeFontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +private fun KycGate(onVerifyIdentity: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SemanticWarn.copy(alpha = KYC_BG_ALPHA)) + .border(width = 1.dp, color = SemanticWarn.copy(alpha = KYC_BORDER_ALPHA)) + .padding(KycPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = SemanticWarn, + modifier = Modifier.size(KycIconSize), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "KYC required for withdrawals over $100", + color = SemanticWarn, + fontSize = KycTitleFontSize, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Verify your identity to unlock larger withdrawals.", + color = TextMedium, + fontSize = KycBodyFontSize, + lineHeight = KycBodyLineHeight, + ) + Row( + modifier = Modifier.clickable(onClick = onVerifyIdentity), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "VERIFY IDENTITY", + color = AccentViolet, + fontSize = KycCtaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(KycCtaChevronSize), + ) + } + } + } +} + +@Composable +private fun WithdrawButton( + enabled: Boolean, + onClick: () -> Unit, +) { + val container = if (enabled) AccentViolet else AccentViolet.copy(alpha = DISABLED_BUTTON_ALPHA) + val content = if (enabled) Color.White else Color.White.copy(alpha = DISABLED_BUTTON_ALPHA) + Box( + modifier = + Modifier + .fillMaxWidth() + .background(container) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = ButtonVerticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "WITHDRAW", + color = content, + fontSize = ButtonFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun FieldLabel(text: String) { + Text( + text = text.uppercase(), + color = TextMedium, + fontSize = SmallLabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) +} + +// --------------------------------------------------------------------------- +// Tokens +// --------------------------------------------------------------------------- + +private val FormPadding = PaddingValues(16.dp) +private val FieldGap = 16.dp + +private val FieldFontSize = 14.sp +private val SmallLabelFontSize = 9.sp + +private val AmountChipGap = 6.dp +private val AmountChipVerticalPadding = 8.dp +private val AmountChipFontSize = 10.sp + +private val FeeFontSize = 13.sp + +private val KycPadding = PaddingValues(16.dp) +private val KycIconSize = 18.dp +private val KycTitleFontSize = 14.sp +private val KycBodyFontSize = 12.sp +private val KycBodyLineHeight = 18.sp +private val KycCtaFontSize = 11.sp +private val KycCtaChevronSize = 12.dp +private const val KYC_BG_ALPHA = 0.05f +private const val KYC_BORDER_ALPHA = 0.50f + +private val ButtonVerticalPadding = 16.dp +private val ButtonFontSize = 12.sp +private const val DISABLED_BUTTON_ALPHA = 0.40f + +private val AMOUNT_PRESETS = listOf("10", "25", "50", "100") diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt index d486041..1637625 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -17,6 +17,8 @@ import com.plainstudio.stackcasino.feature.auth.LoginScreen import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData +import com.plainstudio.stackcasino.feature.wallet.WalletScreen +import com.plainstudio.stackcasino.feature.wallet.previewWalletData /** * Wires every [Route] into a single Compose nav graph. Routes that @@ -62,6 +64,14 @@ fun StackNavHost( onUseCache = {}, ) } + composable(Route.Wallet.path) { + WalletScreen( + data = previewWalletData(), + onNavigate = { route -> + navController.navigate(route.path) { launchSingleTop = true } + }, + ) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -95,7 +105,6 @@ fun StackNavHost( */ private val PLACEHOLDER_ROUTES: List> = listOf( - Route.Wallet to "Wallet", Route.HouseWallet to "House Wallet", Route.History to "History", Route.News to "News", diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/CurrencyDropdown.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/CurrencyDropdown.kt new file mode 100644 index 0000000..4756ea2 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/CurrencyDropdown.kt @@ -0,0 +1,136 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow + +/** + * Currency picker shown under the balance hero on the lobby and the + * wallet. Tapping toggles a DropdownMenu listing the supplied [options]; + * picking one updates the displayed code. + * + * Mockup spec (mockup/js/screens/{lobby,wallet}.js, `data-ccy-toggle`): + * + * trigger: tracked text-[10px] text-txt-lo with chevron-down + * menu: min-w-[120px], border-violet/40, bg-elev + * items: flex gap-2 + 6dp dot (violet on active, transparent + * otherwise) + tracked text-[10px] font-semibold + * + * [networkLabel] is shown next to the code (e.g. "Polygon") and stays + * static because the supported currencies all live on the same network + * in the product today. + */ +@Composable +fun CurrencyDropdown( + initialCurrency: String, + networkLabel: String, + modifier: Modifier = Modifier, + options: List = DefaultOptions, +) { + var expanded by remember { mutableStateOf(false) } + var selected by rememberSaveable { mutableStateOf(initialCurrency) } + Box(modifier = modifier) { + Row( + modifier = Modifier.clickable { expanded = true }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "$selected · $networkLabel", + color = TextLow, + fontSize = LabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(ChevronSize), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(SurfaceElevated), + ) { + options.forEach { code -> + DropdownMenuItem( + text = { CurrencyMenuRow(code = code, isSelected = code == selected) }, + onClick = { + selected = code + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun CurrencyMenuRow( + code: String, + isSelected: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(DotSize) + .background(if (isSelected) AccentViolet else Color.Transparent), + ) + Text( + text = code, + color = if (isSelected) AccentViolet else TextHigh, + fontSize = LabelFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private val DefaultOptions = listOf("USDC", "USDT") +private val LabelFontSize = 10.sp +private val TrackedLetterSpacing = 1.2.sp +private val ChevronSize = 10.dp +private val DotSize = 6.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun CurrencyDropdownPreview() { + StackcasinoTheme { + Box(modifier = Modifier.padding(16.dp)) { + CurrencyDropdown(initialCurrency = "USDC", networkLabel = "Polygon") + } + } +}