From 9335950cbabe8c8a70d5eaf235824f58f4d129f2 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:05:21 -0300 Subject: [PATCH 1/3] refactor(util): move truncateMiddle into util The middle-ellipsis string shortener lived in feature/rounddetail but is also needed by the house wallet address, so promote it to `util/` and route the provably-fair seeds through it. Its test moves alongside. No behavior change. --- .../feature/rounddetail/RoundDetailData.kt | 20 ---------------- .../rounddetail/RoundDetailProvablyFair.kt | 1 + .../stackcasino/util/TruncateMiddle.kt | 23 +++++++++++++++++++ .../TruncateMiddleTest.kt} | 4 ++-- 4 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/util/TruncateMiddle.kt rename app/src/test/java/com/plainstudio/stackcasino/{feature/rounddetail/RoundDetailFormatTest.kt => util/TruncateMiddleTest.kt} (86%) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt index 1963ba3..5416ceb 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailData.kt @@ -73,26 +73,6 @@ data class SeedValue( val value: String, ) -/** - * Shortens a long opaque value to `head...tail` with an ellipsis in the - * middle, leaving short values untouched. Used for the provably-fair - * seeds and hash so the full value stays one clipboard tap away while - * the row keeps a single line. - */ -internal fun truncateMiddle( - value: String, - keepHead: Int = SEED_KEEP_HEAD, - keepTail: Int = SEED_KEEP_TAIL, -): String = - if (value.length <= keepHead + keepTail + 1) { - value - } else { - value.take(keepHead) + "…" + value.takeLast(keepTail) - } - -private const val SEED_KEEP_HEAD = 8 -private const val SEED_KEEP_TAIL = 8 - internal fun roundDetailPreviewData(): RoundDetailData = RoundDetailData( game = GameKey.Crash, diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt index a3cf272..2951741 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailProvablyFair.kt @@ -41,6 +41,7 @@ import com.plainstudio.stackcasino.ui.theme.SurfaceBase import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import com.plainstudio.stackcasino.util.truncateMiddle import kotlinx.coroutines.delay /** diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/TruncateMiddle.kt b/app/src/main/java/com/plainstudio/stackcasino/util/TruncateMiddle.kt new file mode 100644 index 0000000..264c28d --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/TruncateMiddle.kt @@ -0,0 +1,23 @@ +package com.plainstudio.stackcasino.util + +/** + * Shortens a long opaque value to `head...tail` with an ellipsis in the + * middle, leaving short values untouched. Keeps the first and last + * characters legible while the row stays a single line; the full value + * is whatever the caller copies / stores. + * + * Shared by every surface that displays a wallet address, a seed or a + * hash (provably-fair seeds, the house wallet public address, ...). + */ +internal fun truncateMiddle( + value: String, + keepHead: Int = DEFAULT_KEEP, + keepTail: Int = DEFAULT_KEEP, +): String = + if (value.length <= keepHead + keepTail + 1) { + value + } else { + value.take(keepHead) + "…" + value.takeLast(keepTail) + } + +private const val DEFAULT_KEEP = 8 diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt b/app/src/test/java/com/plainstudio/stackcasino/util/TruncateMiddleTest.kt similarity index 86% rename from app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt rename to app/src/test/java/com/plainstudio/stackcasino/util/TruncateMiddleTest.kt index 419aa01..66fcedf 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailFormatTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/util/TruncateMiddleTest.kt @@ -1,9 +1,9 @@ -package com.plainstudio.stackcasino.feature.rounddetail +package com.plainstudio.stackcasino.util import org.junit.Assert.assertEquals import org.junit.Test -class RoundDetailFormatTest { +class TruncateMiddleTest { @Test fun `short values are left untouched`() { assertEquals("48291", truncateMiddle("48291")) From 24f82a54f5c89dd809fccf4642b752d3a4bce17a Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:05:21 -0300 Subject: [PATCH 2/3] feat(ui): add an in-app toast host Adds a top-anchored, slide-in toast with success / error / info / warn styling that mirrors mockup/js/toast.js (colored border + tint, glyph, title + optional body, auto-dismiss). The ToastHostState is hoisted once in StackApp and reachable from any screen through LocalToastController, replacing the generic system Toast for in-app feedback. --- .../com/plainstudio/stackcasino/StackApp.kt | 62 ++++-- .../stackcasino/ui/components/StackToast.kt | 209 ++++++++++++++++++ 2 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/components/StackToast.kt diff --git a/app/src/main/java/com/plainstudio/stackcasino/StackApp.kt b/app/src/main/java/com/plainstudio/stackcasino/StackApp.kt index eaa8028..146283a 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/StackApp.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/StackApp.kt @@ -1,10 +1,14 @@ package com.plainstudio.stackcasino +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController @@ -12,7 +16,10 @@ import com.plainstudio.stackcasino.navigation.PrimaryTab import com.plainstudio.stackcasino.navigation.Route import com.plainstudio.stackcasino.navigation.StackNavHost import com.plainstudio.stackcasino.navigation.StartDestination +import com.plainstudio.stackcasino.ui.components.LocalToastController import com.plainstudio.stackcasino.ui.components.StackBottomBar +import com.plainstudio.stackcasino.ui.components.StackToastHost +import com.plainstudio.stackcasino.ui.components.ToastHostState import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme /** @@ -33,32 +40,41 @@ fun StackApp(startDestination: StartDestination) { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route + val toastState = remember { ToastHostState() } - Scaffold( - modifier = Modifier.fillMaxSize(), - bottomBar = { - if (currentRoute in PrimaryTab.routePaths) { - StackBottomBar( - currentRoute = currentRoute, - onTabSelected = { tab -> - // defaultPath instead of path so parametric tabs - // (Wallet) navigate to the bare destination - // without dragging the {tab} placeholder along. - navController.navigate(tab.route.defaultPath) { - popUpTo(Route.Lobby.path) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, + CompositionLocalProvider(LocalToastController provides toastState) { + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (currentRoute in PrimaryTab.routePaths) { + StackBottomBar( + currentRoute = currentRoute, + onTabSelected = { tab -> + // defaultPath instead of path so parametric tabs + // (Wallet) navigate to the bare destination + // without dragging the {tab} placeholder along. + navController.navigate(tab.route.defaultPath) { + popUpTo(Route.Lobby.path) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + ) + } + }, + ) { padding -> + StackNavHost( + navController = navController, + startDestination = startDestination.route, + modifier = Modifier.padding(padding), ) } - }, - ) { padding -> - StackNavHost( - navController = navController, - startDestination = startDestination.route, - modifier = Modifier.padding(padding), - ) + StackToastHost( + state = toastState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackToast.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackToast.kt new file mode 100644 index 0000000..f46de41 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/StackToast.kt @@ -0,0 +1,209 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Info +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.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SemanticWarn +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.TextMedium +import kotlinx.coroutines.delay + +/** + * Severity of an in-app toast. Maps to a theme accent and a glyph, + * mirroring mockup/js/toast.js (success / error / info / warn). + */ +enum class ToastType { Success, Error, Info, Warn } + +/** A single toast: a required [title] plus an optional [body] line. */ +data class ToastData( + val type: ToastType, + val title: String, + val body: String? = null, +) + +/** + * Holds the toast currently on screen. Hoisted once at the app root and + * reached by any screen through [LocalToastController]; calling [show] + * replaces whatever is visible and restarts the auto-dismiss timer + * (the [revision] counter makes an identical repeat re-trigger it). + */ +@Stable +class ToastHostState { + var current by mutableStateOf(null) + private set + + var revision by mutableIntStateOf(0) + private set + + fun show(data: ToastData) { + current = data + revision++ + } + + fun dismiss() { + current = null + } +} + +/** Provides the app's [ToastHostState] down the tree. */ +val LocalToastController = + staticCompositionLocalOf { + error("No ToastHostState provided. Wrap the app in a CompositionLocalProvider(LocalToastController).") + } + +/** + * Renders the top-anchored toast for [state] with a slide-down entrance + * and an auto-dismiss after [durationMillis]. Mount once near the app + * root, aligned to the top of the window. + */ +@Composable +fun StackToastHost( + state: ToastHostState, + modifier: Modifier = Modifier, + durationMillis: Long = TOAST_DURATION_MILLIS, +) { + LaunchedEffect(state.revision) { + if (state.current != null) { + delay(durationMillis) + state.dismiss() + } + } + // Hold the last payload so the exit animation still has content to + // render after `current` flips to null. + var lastData by remember { mutableStateOf(null) } + LaunchedEffect(state.current) { + if (state.current != null) lastData = state.current + } + + AnimatedVisibility( + visible = state.current != null, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = + modifier.statusBarsPadding().padding( + horizontal = HostHorizontalPadding, + vertical = HostVerticalPadding, + ), + ) { + lastData?.let { data -> ToastCard(data = data, onClick = { state.dismiss() }) } + } +} + +@Composable +private fun ToastCard( + data: ToastData, + onClick: () -> Unit, +) { + val accent = data.type.accent() + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .background(accent.copy(alpha = TINT_ALPHA)) + .border(width = 1.dp, color = accent.copy(alpha = BORDER_ALPHA)) + .clickable(onClick = onClick) + .padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = data.type.icon(), + contentDescription = null, + tint = accent, + modifier = Modifier.size(IconSize), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = data.title, color = accent, fontSize = TitleFontSize, fontWeight = FontWeight.SemiBold) + if (data.body != null) { + Text(text = data.body, color = TextMedium, fontSize = BodyFontSize, lineHeight = BodyLineHeight) + } + } + } +} + +private fun ToastType.accent(): Color = + when (this) { + ToastType.Success -> SemanticOk + ToastType.Error -> SemanticDanger + ToastType.Info -> AccentViolet + ToastType.Warn -> SemanticWarn + } + +private fun ToastType.icon(): ImageVector = + when (this) { + ToastType.Success -> Icons.Outlined.CheckCircle + ToastType.Error -> Icons.Outlined.ErrorOutline + ToastType.Info -> Icons.Outlined.Info + ToastType.Warn -> Icons.Outlined.WarningAmber + } + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12) +@Composable +private fun StackToastPreview() { + StackcasinoTheme { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToastCard(ToastData(ToastType.Success, "Address copied"), onClick = {}) + ToastCard(ToastData(ToastType.Error, "Couldn't open the link"), onClick = {}) + ToastCard( + ToastData(ToastType.Info, "Balance up to date", "Synced from the Polygon RPC."), + onClick = {}, + ) + ToastCard(ToastData(ToastType.Warn, "Verification pending"), onClick = {}) + } + } +} + +private const val TOAST_DURATION_MILLIS = 3000L +private const val TINT_ALPHA = 0.12f +private const val BORDER_ALPHA = 0.50f +private val HostHorizontalPadding = 12.dp +private val HostVerticalPadding = 8.dp +private val CardPadding = 12.dp +private val IconSize = 16.dp +private val TitleFontSize = 12.sp +private val BodyFontSize = 11.sp +private val BodyLineHeight = 15.sp From 164c86a4b87d2e711ce27232b70e7dad84f8cff6 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:05:21 -0300 Subject: [PATCH 3/3] feat(housewallet): port the cu-04b house wallet screen The operator-only wallet management view reached from Profile, with all five mockup states: Generate / Import setup, the active wallet (balance, public address, Android-Keystore private key), the key-revealed view with an auto-hide countdown, the biometric prompt overlay, and the no-biometric error (preview-only). The public address is middle-truncated to a single line via truncateMiddle, and the refresh / copy / Polygonscan chips give feedback through the in-app toast. Replaces the house wallet placeholder in StackNavHost. Tests: HouseWalletFormatTest (the countdown formatter) and HouseWalletScreenTest (the unset / active / revealed states and the back callback). --- CHANGELOG.md | 16 + .../housewallet/HouseWalletScreenTest.kt | 74 +++ .../feature/housewallet/HouseWalletActive.kt | 513 ++++++++++++++++++ .../housewallet/HouseWalletBiometricPrompt.kt | 133 +++++ .../feature/housewallet/HouseWalletScreen.kt | 277 ++++++++++ .../feature/housewallet/HouseWalletUiState.kt | 40 ++ .../feature/housewallet/HouseWalletUnset.kt | 310 +++++++++++ .../stackcasino/navigation/StackNavHost.kt | 5 +- .../housewallet/HouseWalletFormatTest.kt | 18 + 9 files changed, 1385 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreenTest.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUiState.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt create mode 100644 app/src/test/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletFormatTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e35dda..e334745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- House wallet screen (cu-04b): the operator-only wallet management view + reached from Profile. Generate / Import create the wallet, the balance, + public address and Android-Keystore-backed private key card render with + a biometric prompt overlay, revealing the key starts an auto-hide + countdown, and Backup / Revoke / Recent Activity round out the screen. + The public address is middle-truncated to one line, and the refresh, + copy and Polygonscan actions give feedback via the in-app toast. The + no-biometric error is exercised through @Preview. Replaces the house + wallet placeholder in the nav graph. +- In-app toast host (`ui/components/StackToast.kt`): a top-anchored, + slide-in toast with success / error / info / warn styling mirroring the + mockup, hoisted once in `StackApp` and reachable from any screen via + `LocalToastController`. +- Shared `truncateMiddle` string helper moved to `util/` so the + provably-fair seeds and the house wallet address share one + implementation. - KYC screen (cu-16): a static, full-screen identity-verification flow reached from Profile and the wallet withdraw tab. The front and back ID slots simulate an upload (Empty -> Uploading -> Uploaded) with a mock diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreenTest.kt new file mode 100644 index 0000000..841453a --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreenTest.kt @@ -0,0 +1,74 @@ +package com.plainstudio.stackcasino.feature.housewallet + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +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.ui.theme.StackcasinoTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HouseWalletScreenTest { + @get:Rule + val composeRule = createComposeRule() + + private fun setState( + state: HouseWalletUiState, + onBack: () -> Unit = {}, + ) { + composeRule.setContent { + StackcasinoTheme { + HouseWalletScreenContent( + state = state, + secondsRemaining = 7, + onBack = onBack, + onCreateWallet = {}, + onRevealRequest = {}, + onHideKey = {}, + ) + } + } + } + + @Test + fun unset_offers_generate_and_import() { + setState(HouseWalletUiState.Unset) + + composeRule.onNodeWithText("Generate New Wallet").assertIsDisplayed() + composeRule.onNodeWithText("Import Private Key").performScrollTo().assertIsDisplayed() + } + + @Test + fun active_masks_the_key_until_reveal() { + setState(HouseWalletUiState.Wallet(keyRevealed = false)) + + composeRule.onNodeWithText("Reveal with Biometric").performScrollTo().assertIsDisplayed() + composeRule.onAllNodesWithText("Hide Now").assertCountEquals(0) + } + + @Test + fun revealed_shows_the_key_and_the_hide_action() { + setState(HouseWalletUiState.Wallet(keyRevealed = true)) + + composeRule.onNodeWithText("Hide Now").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("0:07").performScrollTo().assertIsDisplayed() + } + + @Test + fun back_button_invokes_the_callback() { + var backPressed = false + setState(HouseWalletUiState.Unset, onBack = { backPressed = true }) + + composeRule.onNodeWithContentDescription("Back").performClick() + + assertTrue(backPressed) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt new file mode 100644 index 0000000..6f603eb --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletActive.kt @@ -0,0 +1,513 @@ +package com.plainstudio.stackcasino.feature.housewallet + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Visibility +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +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.LocalToastController +import com.plainstudio.stackcasino.ui.components.ToastData +import com.plainstudio.stackcasino.ui.components.ToastType +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceElevated +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 +import com.plainstudio.stackcasino.util.truncateMiddle + +/** Configured-wallet state: balance, address, the key card, and admin actions. */ +@Composable +internal fun HouseWalletActiveBody( + keyRevealed: Boolean, + secondsRemaining: Int, + onRevealRequest: () -> Unit, + onHideKey: () -> Unit, +) { + BalanceCard() + AddressCard() + PrivateKeyCard( + keyRevealed = keyRevealed, + secondsRemaining = secondsRemaining, + onRevealRequest = onRevealRequest, + onHideKey = onHideKey, + ) + SecurityWarningCard() + ActionButtons() + RecentActivityCard() +} + +@Composable +private fun BalanceCard() { + val toast = LocalToastController.current + Row( + modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier.size(BalanceIconBoxSize).background(AccentViolet), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.AccountBalanceWallet, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(BalanceIconSize), + ) + } + Column(modifier = Modifier.weight(1f)) { + SectionLabel(text = "Balance") + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$2,450.75", + color = TextHigh, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "USDC · Polygon", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + IconChip( + icon = Icons.Outlined.Refresh, + contentDescription = "Refresh balance", + onClick = { toast.show(ToastData(ToastType.Info, "Balance up to date")) }, + ) + } +} + +@Composable +private fun AddressCard() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val toast = LocalToastController.current + Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel(text = "Public Address") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(modifier = Modifier.size(StatusDotSize).background(SemanticOk)) + Text( + text = "Active", + color = SemanticOk, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .weight(1f) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + text = truncateMiddle(PUBLIC_ADDRESS, keepHead = ADDRESS_KEEP_HEAD, keepTail = ADDRESS_KEEP_TAIL), + color = TextHigh, + fontSize = AddressFontSize, + fontFamily = FontFamily.Monospace, + maxLines = 1, + ) + } + IconChip( + icon = Icons.Outlined.ContentCopy, + contentDescription = "Copy address", + onClick = { + clipboard.setText(AnnotatedString(PUBLIC_ADDRESS)) + toast.show(ToastData(ToastType.Success, "Address copied")) + }, + ) + IconChip( + icon = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = "View on Polygonscan", + onClick = { + if (!context.openUrl("$POLYGONSCAN_ADDRESS_URL$PUBLIC_ADDRESS")) { + toast.show(ToastData(ToastType.Error, "Couldn't open the link")) + } + }, + ) + } + } +} + +@Composable +private fun PrivateKeyCard( + keyRevealed: Boolean, + secondsRemaining: Int, + onRevealRequest: () -> Unit, + onHideKey: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel(text = "Private Key") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(InlineIconSize), + ) + Text( + text = "Android Keystore", + color = AccentViolet, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + if (keyRevealed) { + RevealedKey(secondsRemaining = secondsRemaining, onHideKey = onHideKey) + } else { + MaskedKey(onRevealRequest = onRevealRequest) + } + } +} + +@Composable +private fun MaskedKey(onRevealRequest: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text(text = "•".repeat(MASK_LENGTH), color = TextMedium, fontSize = 14.sp, fontFamily = FontFamily.Monospace) + } + Spacer(modifier = Modifier.height(12.dp)) + OutlineButton(label = "Reveal with Biometric", icon = Icons.Outlined.Visibility, onClick = onRevealRequest) +} + +@Composable +private fun RevealedKey( + secondsRemaining: Int, + onHideKey: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(SemanticDanger.copy(alpha = KEY_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticDanger.copy(alpha = KEY_BORDER_ALPHA)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text(text = PRIVATE_KEY, color = SemanticDanger, fontSize = AddressFontSize, fontFamily = FontFamily.Monospace) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(InlineIconSize), + ) + Text( + text = "Hides in 10s", + color = SemanticDanger, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Text( + text = formatCountdown(secondsRemaining), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + OutlineButton(label = "Hide Now", icon = null, onClick = onHideKey) +} + +@Composable +private fun SecurityWarningCard() { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SemanticDanger.copy(alpha = KEY_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticDanger.copy(alpha = KEY_BORDER_ALPHA)) + .padding(CardPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(WarningIconSize), + ) + Column { + Text(text = "Security Warning", color = SemanticDanger, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "Never share your private key with anyone. Anyone with access to this key can " + + "drain the house wallet and every pending bet.", + color = TextMedium, + fontSize = 12.sp, + lineHeight = 18.sp, + ) + } + } +} + +@Composable +private fun ActionButtons() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionButton( + label = "Backup", + icon = Icons.Outlined.FileDownload, + container = SurfaceElevated, + content = TextHigh, + bordered = true, + modifier = Modifier.weight(1f), + ) + ActionButton( + label = "Revoke Access", + icon = Icons.Outlined.Delete, + container = SemanticDanger, + content = Color.White, + bordered = false, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun RecentActivityCard() { + Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + SectionLabel(text = "Recent Activity") + Spacer(modifier = Modifier.height(12.dp)) + ActivityRow( + dot = SemanticOk, + label = "Payout · Crash", + amount = "-$125.50", + amountColor = SemanticDanger, + time = "2m ago", + ) + Spacer(modifier = Modifier.height(8.dp)) + ActivityRow( + dot = AccentViolet, + label = "Bet collected · Roulette", + amount = "+$25.00", + amountColor = SemanticOk, + time = "4m ago", + ) + Spacer(modifier = Modifier.height(8.dp)) + ActivityRow( + dot = AccentViolet, + label = "Bet collected · Mines", + amount = "+$20.00", + amountColor = SemanticOk, + time = "8m ago", + ) + } +} + +@Composable +private fun ActivityRow( + dot: Color, + label: String, + amount: String, + amountColor: Color, + time: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.size(StatusDotSize).background(dot)) + Text(text = label, color = TextMedium, fontSize = 12.sp) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text = amount, color = amountColor, fontSize = 12.sp, style = TabularNums) + Text( + text = time, + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + } +} + +@Composable +private fun IconChip( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(ChipSize) + .background(SurfaceElevated) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = TextMedium, + modifier = Modifier.size(ChipIconSize), + ) + } +} + +@Composable +private fun OutlineButton( + label: String, + icon: ImageVector?, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = AccentViolet) + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(InlineIconSize), + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Text( + text = label, + color = AccentViolet, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun ActionButton( + label: String, + icon: ImageVector, + container: Color, + content: Color, + bordered: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .background(container) + .then(if (bordered) Modifier.border(width = 1.dp, color = SurfaceOutline) else Modifier) + .clickable { /* backup / revoke ship with the wallet repository */ } + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = icon, contentDescription = null, tint = content, modifier = Modifier.size(InlineIconSize)) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = label, + color = content, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +/** Opens [url] in the browser; returns false if no app can handle it. */ +private fun Context.openUrl(url: String): Boolean = + runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }.isSuccess + +private const val POLYGONSCAN_ADDRESS_URL = "https://polygonscan.com/address/" +private const val PUBLIC_ADDRESS = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" +private const val PRIVATE_KEY = "0x3fae88c52e1f8b9c41d7a6e2f5b3c8d9a0e4f7b1c8d3e5f2a9b6c4d1e8f5a3b7c" +private const val MASK_LENGTH = 36 +private const val ADDRESS_KEEP_HEAD = 10 +private const val ADDRESS_KEEP_TAIL = 8 +private const val KEY_TINT_ALPHA = 0.05f +private const val KEY_BORDER_ALPHA = 0.40f + +private val BalanceIconBoxSize = 48.dp +private val BalanceIconSize = 22.dp +private val InlineIconSize = 12.dp +private val WarningIconSize = 18.dp +private val ChipSize = 44.dp +private val ChipIconSize = 14.dp +private val StatusDotSize = 6.dp +private val AddressFontSize = 11.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt new file mode 100644 index 0000000..ed5ff4a --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletBiometricPrompt.kt @@ -0,0 +1,133 @@ +package com.plainstudio.stackcasino.feature.housewallet + +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Fingerprint +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.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Biometric prompt overlay shown while revealing the private key. The + * fingerprint glyph and "Use PIN" simulate a successful unlock (the real + * BiometricPrompt arrives with the wallet repository); Cancel dismisses + * without revealing. + */ +@Composable +internal fun HouseWalletBiometricPrompt( + onAuthenticate: () -> Unit, + onCancel: () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = SCRIM_ALPHA)).padding(PromptPadding), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxWidth().background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(PromptInnerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(FingerprintBoxSize) + .border(width = 2.dp, color = AccentViolet.copy(alpha = FINGERPRINT_BORDER_ALPHA)) + .clickable(onClick = onAuthenticate), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Fingerprint, + contentDescription = "Authenticate", + tint = AccentViolet, + modifier = Modifier.size(FingerprintIconSize), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Authenticate", color = TextHigh, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Touch the fingerprint sensor to continue", + color = TextMedium, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(20.dp)) + HairlineDivider() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "BiometricPrompt · Android Keystore", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + HairlineDivider() + Row(modifier = Modifier.fillMaxWidth()) { + PromptAction(label = "Cancel", color = TextMedium, onClick = onCancel, modifier = Modifier.weight(1f)) + Box(modifier = Modifier.width(1.dp).height(PromptActionHeight).background(SurfaceOutline)) + PromptAction( + label = "Use PIN", + color = AccentViolet, + onClick = onAuthenticate, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PromptAction( + label: String, + color: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.height(PromptActionHeight).clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = color, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private const val SCRIM_ALPHA = 0.70f +private const val FINGERPRINT_BORDER_ALPHA = 0.40f +private val PromptPadding = 24.dp +private val PromptInnerPadding = 24.dp +private val FingerprintBoxSize = 64.dp +private val FingerprintIconSize = 32.dp +private val PromptActionHeight = 48.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt new file mode 100644 index 0000000..1dc2af2 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletScreen.kt @@ -0,0 +1,277 @@ +package com.plainstudio.stackcasino.feature.housewallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.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.AccentViolet +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextMedium +import kotlinx.coroutines.delay + +/** + * House wallet screen reproducing the cu-04b mockup + * (mockup/js/screens/house-wallet.js). The operator configures the + * wallet that funds payouts and collects losing bets. + * + * Static shell: whether a wallet exists, whether its key is revealed and + * whether the biometric prompt is showing live as screen-local state. + * Generate / Import create the wallet, "Reveal with Biometric" raises the + * prompt, authenticating reveals the key (which auto-hides after a short + * countdown), and Hide Now masks it again. Real key generation / import, + * the Android Keystore and the on-chain balance arrive with the wallet + * repository; the no-biometric error is exercised through @Preview. + */ +@Composable +fun HouseWalletScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var hasWallet by rememberSaveable { mutableStateOf(false) } + var keyRevealed by rememberSaveable { mutableStateOf(false) } + var showPrompt by rememberSaveable { mutableStateOf(false) } + var secondsRemaining by remember { mutableIntStateOf(REVEAL_SECONDS) } + + LaunchedEffect(keyRevealed) { + if (keyRevealed) { + secondsRemaining = REVEAL_SECONDS + while (secondsRemaining > 0) { + delay(COUNTDOWN_TICK_MILLIS) + secondsRemaining-- + } + keyRevealed = false + } + } + + val state = if (hasWallet) HouseWalletUiState.Wallet(keyRevealed) else HouseWalletUiState.Unset + + Box(modifier = modifier.fillMaxSize()) { + HouseWalletScreenContent( + state = state, + secondsRemaining = secondsRemaining, + onBack = onBack, + onCreateWallet = { hasWallet = true }, + onRevealRequest = { showPrompt = true }, + onHideKey = { keyRevealed = false }, + ) + if (showPrompt) { + HouseWalletBiometricPrompt( + onAuthenticate = { + showPrompt = false + keyRevealed = true + }, + onCancel = { showPrompt = false }, + ) + } + } +} + +@Composable +internal fun HouseWalletScreenContent( + state: HouseWalletUiState, + secondsRemaining: Int, + onBack: () -> Unit, + onCreateWallet: () -> Unit, + onRevealRequest: () -> Unit, + onHideKey: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize()) { + HouseWalletHeader(onBack = onBack) + HairlineDivider() + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(ScreenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + when (state) { + HouseWalletUiState.Unset -> HouseWalletUnsetBody(onCreateWallet = onCreateWallet) + HouseWalletUiState.NoBiometric -> HouseWalletNoBiometric() + is HouseWalletUiState.Wallet -> + HouseWalletActiveBody( + keyRevealed = state.keyRevealed, + secondsRemaining = secondsRemaining, + onRevealRequest = onRevealRequest, + onHideKey = onHideKey, + ) + } + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } + } + } +} + +@Composable +private fun HouseWalletHeader(onBack: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .size(HeaderButtonSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Back", + tint = TextHigh, + modifier = Modifier.size(HeaderIconSize), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(text = "House Wallet", color = TextHigh, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text( + text = "Operator Only", + color = AccentViolet, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + BiometricBadge() + } +} + +@Composable +private fun BiometricBadge() { + Row( + modifier = + Modifier + .background(AccentViolet.copy(alpha = BADGE_TINT_ALPHA)) + .border(width = 1.dp, color = AccentViolet.copy(alpha = BADGE_BORDER_ALPHA)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(BadgeIconSize), + ) + Text( + text = "Biometric", + color = AccentViolet, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +// --------------------------------------------------------------------------- +// Shared building blocks for the house wallet section files +// --------------------------------------------------------------------------- + +internal fun Modifier.hwCardSurface(): Modifier = background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) + +@Composable +internal fun HairlineDivider() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +@Composable +internal fun SectionLabel( + text: String, + color: Color = TextMedium, +) { + Text(text = text, color = color, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) +} + +internal val ScreenHorizontalPadding = 16.dp +internal val SectionGap = 12.dp +internal val CardPadding = 16.dp +internal val TrackedLetterSpacing = 1.2.sp +internal val MetaFontSize = 10.sp +internal val SmallMetaFontSize = 9.sp +internal val TabularNums = TextStyle(fontFeatureSettings = "tnum") + +internal const val REVEAL_SECONDS = 10 +private const val COUNTDOWN_TICK_MILLIS = 1000L +private const val BADGE_TINT_ALPHA = 0.15f +private const val BADGE_BORDER_ALPHA = 0.40f +private val BottomScrollPadding = 24.dp +private val HeaderButtonSize = 36.dp +private val HeaderIconSize = 16.dp +private val BadgeIconSize = 10.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1000) +@Composable +private fun HouseWalletUnsetPreview() { + StackcasinoTheme { HouseWalletPreview(HouseWalletUiState.Unset) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1400) +@Composable +private fun HouseWalletActivePreview() { + StackcasinoTheme { HouseWalletPreview(HouseWalletUiState.Wallet(keyRevealed = false)) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1400) +@Composable +private fun HouseWalletKeyRevealedPreview() { + StackcasinoTheme { HouseWalletPreview(HouseWalletUiState.Wallet(keyRevealed = true)) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 600) +@Composable +private fun HouseWalletNoBiometricPreview() { + StackcasinoTheme { HouseWalletPreview(HouseWalletUiState.NoBiometric) } +} + +@Composable +private fun HouseWalletPreview(state: HouseWalletUiState) { + HouseWalletScreenContent( + state = state, + secondsRemaining = 7, + onBack = {}, + onCreateWallet = {}, + onRevealRequest = {}, + onHideKey = {}, + ) +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUiState.kt new file mode 100644 index 0000000..4eace3c --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUiState.kt @@ -0,0 +1,40 @@ +package com.plainstudio.stackcasino.feature.housewallet + +/** + * UI state for the operator-only house wallet screen. + * + * Mirrors the states drawn by the mockup + * (mockup/js/screens/house-wallet.js): + * + * * [Unset] is the first-run setup (generate or import a wallet). + * * [Wallet] is the configured wallet; [keyRevealed] toggles the + * private key between the masked view and the revealed view (the + * mockup's active / key-shown states). + * * [NoBiometric] is the device-can't-comply error. + * + * The biometric prompt is modeled as separate screen-local state layered + * over [Wallet], not as a member here, since it is a transient overlay. + * Only [Unset] and [Wallet] are reachable from user actions in this + * static shell; [NoBiometric] is exercised through @Preview. + */ +sealed interface HouseWalletUiState { + data object Unset : HouseWalletUiState + + data object NoBiometric : HouseWalletUiState + + data class Wallet( + val keyRevealed: Boolean, + ) : HouseWalletUiState +} + +/** + * Formats the reveal auto-hide countdown as `m:ss`, matching the + * mockup's "0:07". Seconds are clamped to two digits. + */ +internal fun formatCountdown(totalSeconds: Int): String { + val minutes = totalSeconds / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + +private const val SECONDS_PER_MINUTE = 60 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt new file mode 100644 index 0000000..0196171 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletUnset.kt @@ -0,0 +1,310 @@ +package com.plainstudio.stackcasino.feature.housewallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VpnKey +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.graphics.vector.ImageVector +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.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.SemanticOk +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.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** Setup state: the intro plus the generate / import choice cards. */ +@Composable +internal fun HouseWalletUnsetBody(onCreateWallet: () -> Unit) { + IntroCard() + GenerateCard(onCreate = onCreateWallet) + ImportCard(onImport = onCreateWallet) +} + +@Composable +private fun IntroCard() { + Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardPadding)) { + Text( + text = + "Configure the wallet that will act as the house. This wallet funds player " + + "payouts and receives losing bets. The private key is encrypted with Android " + + "Keystore and never leaves this device.", + color = TextMedium, + fontSize = 13.sp, + lineHeight = 20.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + HairlineDivider() + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + tint = AccentVioletSoft, + modifier = Modifier.size(NoteIconSize), + ) + Text( + text = "Biometric required for every key operation", + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun GenerateCard(onCreate: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .hwCardSurface() + .clickable(onClick = onCreate) + .padding(CardLargePadding), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ChoiceIcon(icon = Icons.Outlined.AccountBalanceWallet) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "Generate New Wallet", color = TextHigh, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + RecommendedBadge() + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Derive a fresh keypair on this device. Fastest setup for staging environments.", + color = TextMedium, + fontSize = 12.sp, + lineHeight = 18.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "BIP-39 · 256-bit entropy", + color = AccentViolet, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(InlineIconSize), + ) + } + } + } +} + +@Composable +private fun ImportCard(onImport: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().hwCardSurface().padding(CardLargePadding)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ChoiceIcon(icon = Icons.Outlined.VpnKey) + Column(modifier = Modifier.weight(1f)) { + Text(text = "Import Private Key", color = TextHigh, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Paste an existing hot wallet key. Scale to production without code changes.", + color = TextMedium, + fontSize = 12.sp, + lineHeight = 18.sp, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + SectionLabel(text = "Private Key", color = TextMedium) + Spacer(modifier = Modifier.height(6.dp)) + KeyInputStub() + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Key is encrypted locally · never transmitted", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(16.dp)) + PrimaryButton(label = "Import Wallet", onClick = onImport) + } +} + +@Composable +private fun KeyInputStub() { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .weight(1f) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text(text = "0x...", color = TextLow, fontSize = 13.sp, fontFamily = FontFamily.Monospace) + } + Box( + modifier = + Modifier + .size(InputButtonSize) + .background(SurfaceElevated) + .border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Visibility, + contentDescription = "Toggle key visibility", + tint = TextMedium, + modifier = Modifier.size(InlineIconSize), + ) + } + } +} + +@Composable +internal fun PrimaryButton( + label: String, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(AccentViolet) + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun ChoiceIcon(icon: ImageVector) { + Box( + modifier = + Modifier + .size(ChoiceIconBoxSize) + .background(AccentViolet.copy(alpha = ICON_TINT_ALPHA)) + .border(width = 1.dp, color = AccentViolet.copy(alpha = ICON_BORDER_ALPHA)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(ChoiceIconSize), + ) + } +} + +@Composable +private fun RecommendedBadge() { + Box( + modifier = + Modifier + .background(SemanticOk.copy(alpha = ICON_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticOk.copy(alpha = ICON_BORDER_ALPHA)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = "Recommended", + color = SemanticOk, + fontSize = TinyFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +/** Device-can't-comply error state. */ +@Composable +internal fun HouseWalletNoBiometric() { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SemanticDanger.copy(alpha = ERROR_TINT_ALPHA)) + .border(width = 1.dp, color = SemanticDanger.copy(alpha = ERROR_BORDER_ALPHA)) + .padding(CardPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(ErrorIconSize), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Biometric Not Configured", + color = SemanticDanger, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "This device has no fingerprint, face unlock, or device PIN set. House wallet " + + "operations require a system unlock method.", + color = TextMedium, + fontSize = 12.sp, + lineHeight = 18.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Open System Settings", + color = AccentViolet, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.clickable { /* opens system settings once wired */ }, + ) + } + } +} + +private const val ICON_TINT_ALPHA = 0.15f +private const val ICON_BORDER_ALPHA = 0.40f +private const val ERROR_TINT_ALPHA = 0.05f +private const val ERROR_BORDER_ALPHA = 0.50f +private val CardLargePadding = 20.dp +private val NoteIconSize = 14.dp +private val InlineIconSize = 12.dp +private val ChoiceIconBoxSize = 48.dp +private val ChoiceIconSize = 22.dp +private val InputButtonSize = 44.dp +private val ErrorIconSize = 18.dp +private val TinyFontSize = 8.sp 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 391e894..a8690b1 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,7 @@ import com.plainstudio.stackcasino.feature.assistant.AssistantScreen import com.plainstudio.stackcasino.feature.auth.LoginScreen import com.plainstudio.stackcasino.feature.history.HistoryScreen import com.plainstudio.stackcasino.feature.history.historyPreviewData +import com.plainstudio.stackcasino.feature.housewallet.HouseWalletScreen import com.plainstudio.stackcasino.feature.kyc.KycScreen import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState @@ -119,6 +120,9 @@ fun StackNavHost( composable(Route.Kyc.path) { KycScreen(onBack = { navController.popBackStack() }) } + composable(Route.HouseWallet.path) { + HouseWalletScreen(onBack = { navController.popBackStack() }) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -189,7 +193,6 @@ private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController */ private val PLACEHOLDER_ROUTES: List> = listOf( - Route.HouseWallet to "House Wallet", Route.Coinflip to "Coinflip", Route.Roulette to "Roulette", Route.Crash to "Crash", diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletFormatTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletFormatTest.kt new file mode 100644 index 0000000..0864521 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/housewallet/HouseWalletFormatTest.kt @@ -0,0 +1,18 @@ +package com.plainstudio.stackcasino.feature.housewallet + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HouseWalletFormatTest { + @Test + fun `pads seconds to two digits`() { + assertEquals("0:07", formatCountdown(7)) + assertEquals("0:00", formatCountdown(0)) + } + + @Test + fun `rolls seconds into minutes`() { + assertEquals("0:10", formatCountdown(10)) + assertEquals("1:05", formatCountdown(65)) + } +}