diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f73156..2e35dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 + DNI preview, the progress meter and submit gate track both photos, and + Submit advances to the review banner. The review outcomes (Verified, + Suspended, Error) are exercised through @Preview until the KYC + repository drives them. Replaces the KYC placeholder in the nav graph. - Round detail screen (cu-11): a static, full-screen view reached from the history list with the result banner, the three-up stats grid, the crash position bar, the round timeline, a collapsible provably-fair diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/kyc/KycScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/kyc/KycScreenTest.kt new file mode 100644 index 0000000..84cfbcb --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/kyc/KycScreenTest.kt @@ -0,0 +1,88 @@ +package com.plainstudio.stackcasino.feature.kyc + +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.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 KycScreenTest { + @get:Rule + val composeRule = createComposeRule() + + private fun setForm( + front: SlotStatus, + back: SlotStatus, + onBack: () -> Unit = {}, + ) { + composeRule.setContent { + StackcasinoTheme { + KycScreenContent( + state = KycUiState.Form(front, back), + onBack = onBack, + onPickFront = {}, + onReplaceFront = {}, + onPickBack = {}, + onReplaceBack = {}, + onSubmit = {}, + ) + } + } + } + + @Test + fun empty_form_shows_both_dropzones_and_the_submit_hint() { + setForm(SlotStatus.Empty, SlotStatus.Empty) + + composeRule.onNodeWithText("Upload front of ID").assertIsDisplayed() + composeRule.onNodeWithText("Upload back of ID").assertIsDisplayed() + composeRule.onNodeWithText("Upload both photos to continue").assertIsDisplayed() + } + + @Test + fun both_uploaded_drops_the_submit_hint() { + setForm(SlotStatus.Uploaded, SlotStatus.Uploaded) + + composeRule.onNodeWithText("Submit for Verification").assertIsDisplayed() + composeRule.onAllNodesWithText("Upload both photos to continue").assertCountEquals(0) + } + + @Test + fun pending_state_shows_only_the_review_banner() { + composeRule.setContent { + StackcasinoTheme { + KycScreenContent( + state = KycUiState.Pending, + onBack = {}, + onPickFront = {}, + onReplaceFront = {}, + onPickBack = {}, + onReplaceBack = {}, + onSubmit = {}, + ) + } + } + + composeRule.onNodeWithText("Verification Under Review").assertIsDisplayed() + composeRule.onAllNodesWithText("Upload front of ID").assertCountEquals(0) + } + + @Test + fun back_button_invokes_the_callback() { + var backPressed = false + setForm(SlotStatus.Empty, SlotStatus.Empty, onBack = { backPressed = true }) + + composeRule.onNodeWithContentDescription("Back").performClick() + + assertTrue(backPressed) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycBanners.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycBanners.kt new file mode 100644 index 0000000..b18a2a6 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycBanners.kt @@ -0,0 +1,127 @@ +package com.plainstudio.stackcasino.feature.kyc + +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.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.CheckCircle +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Schedule +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.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.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.TextMedium + +/** + * Review-outcome banners. Each collapses the KYC screen to a single + * tinted status card (the mockup hides the upload form in these states). + * The actions are visual stubs until the KYC repository ships. + */ +@Composable +internal fun KycPendingBanner() { + KycBanner( + accent = SemanticWarn, + icon = Icons.Outlined.Schedule, + title = "Verification Under Review", + message = + "Submitted 4/14/2026 · 9:22 AM. Our team typically reviews submissions within " + + "24 hours. You'll get a push notification when it's resolved.", + ) +} + +@Composable +internal fun KycVerifiedBanner() { + KycBanner( + accent = SemanticOk, + icon = Icons.Outlined.CheckCircle, + title = "Identity Verified", + message = "Your account has full withdrawal access. Approved 4/15/2026 · 11:08 AM.", + ) +} + +@Composable +internal fun KycSuspendedBanner() { + KycBanner( + accent = SemanticDanger, + icon = Icons.Outlined.ErrorOutline, + title = "Verification Rejected", + message = "Photos were blurry or the document edges were cropped. Contact support to re-submit.", + actionLabel = "Contact Support", + ) +} + +@Composable +internal fun KycErrorBanner() { + KycBanner( + accent = SemanticDanger, + icon = Icons.Outlined.WarningAmber, + title = "Upload Failed", + message = "Network error during upload to Firebase Storage. Check your connection and try again.", + actionLabel = "Retry Upload", + ) +} + +@Composable +private fun KycBanner( + accent: Color, + icon: ImageVector, + title: String, + message: String, + actionLabel: String? = null, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(accent.copy(alpha = BANNER_TINT_ALPHA)) + .border(width = 1.dp, color = accent.copy(alpha = BANNER_BORDER_ALPHA)) + .padding(CardPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = accent, + modifier = Modifier.size(BannerIconSize).padding(top = 2.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, color = accent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = message, color = TextMedium, fontSize = 12.sp, lineHeight = 18.sp) + if (actionLabel != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = actionLabel, + color = AccentViolet, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.clickable { /* support / retry ships with the KYC repository */ }, + ) + } + } + } +} + +private const val BANNER_TINT_ALPHA = 0.05f +private const val BANNER_BORDER_ALPHA = 0.50f +private val BannerIconSize = 18.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt new file mode 100644 index 0000000..f3c15b1 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycScreen.kt @@ -0,0 +1,386 @@ +package com.plainstudio.stackcasino.feature.kyc + +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.Shield +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.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.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium +import kotlinx.coroutines.delay + +/** + * KYC screen reproducing the cu-16 mockup (mockup/js/screens/kyc.js). + * + * Static shell: the front and back upload slots plus a submit flag live + * as screen-local state. Tapping Camera / Gallery simulates an upload + * (Empty -> Uploading -> Uploaded), Submit moves to the Pending review + * banner, and Replace clears a slot. Real camera / gallery capture and + * the Firebase Storage upload land with the KYC repository; the review + * outcomes (Verified / Suspended / Error) arrive from that data source + * and are exercised here through @Preview. + */ +@Composable +fun KycScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var front by rememberSaveable { mutableStateOf(SlotStatus.Empty) } + var back by rememberSaveable { mutableStateOf(SlotStatus.Empty) } + var submitted by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(front) { + if (front == SlotStatus.Uploading) { + delay(UPLOAD_SIMULATION_MILLIS) + front = SlotStatus.Uploaded + } + } + LaunchedEffect(back) { + if (back == SlotStatus.Uploading) { + delay(UPLOAD_SIMULATION_MILLIS) + back = SlotStatus.Uploaded + } + } + + val state: KycUiState = if (submitted) KycUiState.Pending else KycUiState.Form(front, back) + + KycScreenContent( + state = state, + onBack = onBack, + onPickFront = { if (front == SlotStatus.Empty) front = SlotStatus.Uploading }, + onReplaceFront = { front = SlotStatus.Empty }, + onPickBack = { if (back == SlotStatus.Empty) back = SlotStatus.Uploading }, + onReplaceBack = { back = SlotStatus.Empty }, + onSubmit = { if (KycUiState.Form(front, back).canSubmit()) submitted = true }, + modifier = modifier, + ) +} + +@Composable +internal fun KycScreenContent( + state: KycUiState, + onBack: () -> Unit, + onPickFront: () -> Unit, + onReplaceFront: () -> Unit, + onPickBack: () -> Unit, + onReplaceBack: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize()) { + KycHeader(onBack = onBack) + HairlineDivider() + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(ScreenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + when (state) { + is KycUiState.Form -> + KycFormBody( + form = state, + onPickFront = onPickFront, + onReplaceFront = onReplaceFront, + onPickBack = onPickBack, + onReplaceBack = onReplaceBack, + onSubmit = onSubmit, + ) + KycUiState.Pending -> KycPendingBanner() + KycUiState.Verified -> KycVerifiedBanner() + KycUiState.Suspended -> KycSuspendedBanner() + KycUiState.Error -> KycErrorBanner() + } + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } + } + } +} + +@Composable +private fun KycHeader(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 = "Profile · KYC", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text(text = "Verify Identity", color = TextHigh, fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun KycFormBody( + form: KycUiState.Form, + onPickFront: () -> Unit, + onReplaceFront: () -> Unit, + onPickBack: () -> Unit, + onReplaceBack: () -> Unit, + onSubmit: () -> Unit, +) { + IntroCard() + ProgressMeter(uploadedCount = form.uploadedCount()) + KycSlot( + title = "ID Front", + fileName = "dni_front.jpg", + status = form.front, + kind = SlotKind.Front, + onPick = onPickFront, + onReplace = onReplaceFront, + ) + KycSlot( + title = "ID Back", + fileName = "dni_back.jpg", + status = form.back, + kind = SlotKind.Back, + onPick = onPickBack, + onReplace = onReplaceBack, + ) + SubmitCta(enabled = form.canSubmit(), onSubmit = onSubmit) +} + +@Composable +private fun IntroCard() { + Column(modifier = Modifier.fillMaxWidth().kycCardSurface().padding(CardPadding)) { + Text( + text = + "To comply with regulations and unlock withdrawals over $100, upload clear " + + "photos of both sides of your government-issued ID.", + 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 = "Encrypted · deleted from storage after review", + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun ProgressMeter(uploadedCount: Int) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Progress", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = "$uploadedCount/$REQUIRED_PHOTOS photos", + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .height(ProgressBarHeight) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(uploadedCount.toFloat() / REQUIRED_PHOTOS) + .height(ProgressBarHeight) + .background(AccentViolet), + ) + } + } +} + +@Composable +private fun SubmitCta( + enabled: Boolean, + onSubmit: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(AccentViolet.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) + .clickable(enabled = enabled, onClick = onSubmit) + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Submit for Verification", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } + if (!enabled) { + Text( + text = "Upload both photos to continue", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +// --------------------------------------------------------------------------- +// Shared building blocks for the KYC section files +// --------------------------------------------------------------------------- + +internal fun Modifier.kycCardSurface(): Modifier = + background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) + +@Composable +internal fun HairlineDivider() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +internal val ScreenHorizontalPadding = 16.dp +internal val SectionGap = 16.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") + +private const val UPLOAD_SIMULATION_MILLIS = 1200L +private const val REQUIRED_PHOTOS = 2 +private const val DISABLED_ALPHA = 0.40f +private val BottomScrollPadding = 24.dp +private val HeaderButtonSize = 36.dp +private val HeaderIconSize = 16.dp +private val NoteIconSize = 14.dp +private val ProgressBarHeight = 4.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200) +@Composable +private fun KycEmptyPreview() { + StackcasinoTheme { KycPreview(KycUiState.Form(SlotStatus.Empty, SlotStatus.Empty)) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200) +@Composable +private fun KycBothUploadedPreview() { + StackcasinoTheme { KycPreview(KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Uploaded)) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200) +@Composable +private fun KycUploadingPreview() { + StackcasinoTheme { KycPreview(KycUiState.Form(SlotStatus.Uploading, SlotStatus.Empty)) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 600) +@Composable +private fun KycPendingPreview() { + StackcasinoTheme { KycPreview(KycUiState.Pending) } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 600) +@Composable +private fun KycSuspendedPreview() { + StackcasinoTheme { KycPreview(KycUiState.Suspended) } +} + +@Composable +private fun KycPreview(state: KycUiState) { + KycScreenContent( + state = state, + onBack = {}, + onPickFront = {}, + onReplaceFront = {}, + onPickBack = {}, + onReplaceBack = {}, + onSubmit = {}, + ) +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt new file mode 100644 index 0000000..b20648b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycSlot.kt @@ -0,0 +1,360 @@ +package com.plainstudio.stackcasino.feature.kyc + +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.aspectRatio +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.Check +import androidx.compose.material.icons.outlined.FileUpload +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material3.CircularProgressIndicator +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.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +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.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 + +/** + * A single ID photo slot: the empty dropzone with Camera / Gallery + * actions, the in-progress upload view, or the uploaded document preview + * with a Replace affordance. + */ +@Composable +internal fun KycSlot( + title: String, + fileName: String, + status: SlotStatus, + kind: SlotKind, + onPick: () -> Unit, + onReplace: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().kycCardSurface().padding(CardPadding)) { + SlotHeader(title = title) + Spacer(modifier = Modifier.height(12.dp)) + when (status) { + SlotStatus.Empty -> { + EmptyDropzone(kind = kind) + Spacer(modifier = Modifier.height(12.dp)) + PickActions(onPick = onPick) + } + SlotStatus.Uploading -> UploadingView(fileName = fileName) + SlotStatus.Uploaded -> { + DocumentPreview(kind = kind) + Spacer(modifier = Modifier.height(12.dp)) + ReplaceRow(fileName = fileName, onReplace = onReplace) + } + } + } +} + +@Composable +private fun SlotHeader(title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + color = TextHigh, + fontSize = MetaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Text(text = "· Required", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + } + Text( + text = "JPEG · max 5 MB", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun EmptyDropzone(kind: SlotKind) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(SLOT_ASPECT_RATIO) + .background(SurfaceBase) + .dashedBorder(SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Outlined.FileUpload, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(DropzoneIconSize), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (kind == SlotKind.Front) "Upload front of ID" else "Upload back of ID", + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun UploadingView(fileName: String) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(SLOT_ASPECT_RATIO) + .background(AccentViolet.copy(alpha = UPLOADING_TINT_ALPHA)) + .border(width = 1.dp, color = AccentViolet.copy(alpha = UPLOADING_BORDER_ALPHA)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(SpinnerSize), + color = AccentViolet, + strokeWidth = SpinnerStroke, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Uploading to Firebase Storage", + color = AccentViolet, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = + Modifier + .fillMaxWidth(UPLOAD_TRACK_FRACTION) + .height(UploadBarHeight) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(UPLOAD_PROGRESS_FRACTION) + .height(UploadBarHeight) + .background(AccentViolet), + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "72% · $fileName · 2.1 MB", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun DocumentPreview(kind: SlotKind) { + val gradient = + if (kind == SlotKind.Front) { + Brush.linearGradient(listOf(DocFrontGradientTop, DocFrontGradientBottom)) + } else { + Brush.linearGradient(listOf(DocBackGradientTop, DocBackGradientBottom)) + } + Box(modifier = Modifier.fillMaxWidth().aspectRatio(SLOT_ASPECT_RATIO).background(gradient)) { + Column(modifier = Modifier.padding(12.dp)) { + if (kind == SlotKind.Front) { + DocumentLine(label = "REPUBLICA ARGENTINA", value = "DNI 47.311.861") + Spacer(modifier = Modifier.height(8.dp)) + DocumentLine(label = "APELLIDO", value = "MAZZARA") + DocumentLine(label = "NOMBRE", value = "MAURO") + } else { + DocumentLine(label = "DOMICILIO", value = "AV. LIBERTADOR 1234, CABA") + Spacer(modifier = Modifier.height(8.dp)) + DocumentLine(label = "EJEMPLAR", value = "A") + } + } + UploadedBadge(modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) + } +} + +@Composable +private fun DocumentLine( + label: String, + value: String, +) { + Text( + text = label, + color = Color.White.copy(alpha = DOC_LABEL_ALPHA), + fontSize = DocLabelFontSize, + fontFamily = FontFamily.Monospace, + ) + Text( + text = value, + color = Color.White, + fontSize = DocValueFontSize, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) +} + +@Composable +private fun UploadedBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier.background(SemanticOk).padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(BadgeIconSize), + ) + Text( + text = "Uploaded", + color = Color.White, + fontSize = SmallMetaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun PickActions(onPick: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PickButton( + label = "Camera", + icon = Icons.Outlined.PhotoCamera, + filled = true, + onClick = onPick, + modifier = Modifier.weight(1f), + ) + PickButton( + label = "Gallery", + icon = Icons.Outlined.Image, + filled = false, + onClick = onPick, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun PickButton( + label: String, + icon: ImageVector, + filled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = if (filled) AccentViolet else SurfaceElevated + val content = if (filled) Color.White else TextHigh + Row( + modifier = + modifier + .background(background) + .then(if (filled) Modifier else Modifier.border(width = 1.dp, color = SurfaceOutline)) + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(imageVector = icon, contentDescription = null, tint = content, modifier = Modifier.size(PickIconSize)) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = label, color = content, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun ReplaceRow( + fileName: String, + onReplace: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$fileName · 2.1 MB", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = "Replace", + color = AccentViolet, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.clickable(onClick = onReplace), + ) + } +} + +private fun Modifier.dashedBorder(color: Color): Modifier = + drawBehind { + drawRect( + color = color, + style = + Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(DASH_ON_PX, DASH_OFF_PX)), + ), + ) + } + +// Mock DNI document gradients (decorative; the real capture replaces them). +private val DocFrontGradientTop = Color(0xFF3A2D1A) +private val DocFrontGradientBottom = Color(0xFF1A1612) +private val DocBackGradientTop = Color(0xFF2A2A3A) +private val DocBackGradientBottom = Color(0xFF15141F) + +private const val SLOT_ASPECT_RATIO = 1.6f +private const val UPLOAD_TRACK_FRACTION = 0.66f +private const val UPLOAD_PROGRESS_FRACTION = 0.72f +private const val UPLOADING_TINT_ALPHA = 0.05f +private const val UPLOADING_BORDER_ALPHA = 0.40f +private const val DOC_LABEL_ALPHA = 0.5f +private const val DASH_ON_PX = 12f +private const val DASH_OFF_PX = 8f + +private val DropzoneIconSize = 28.dp +private val SpinnerSize = 24.dp +private val SpinnerStroke = 2.dp +private val UploadBarHeight = 4.dp +private val BadgeIconSize = 10.dp +private val PickIconSize = 16.dp +private val DocLabelFontSize = 7.sp +private val DocValueFontSize = 9.sp +private val TinyFontSize = 8.sp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycUiState.kt new file mode 100644 index 0000000..af58f3d --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/kyc/KycUiState.kt @@ -0,0 +1,46 @@ +package com.plainstudio.stackcasino.feature.kyc + +/** + * UI state for the KYC screen. + * + * Mirrors the states drawn by the mockup (mockup/js/screens/kyc.js): + * + * * [Form] is the upload flow (the mockup's empty / one / both / + * uploading states), parameterized by each slot's [SlotStatus]: + * "one" is front uploaded with back still empty, "both" is both + * uploaded, and a slot mid-upload yields the uploading view. + * * [Pending], [Verified] and [Suspended] are the review outcomes and + * [Error] is an upload failure; each collapses the form to a single + * status banner, matching the mockup where those states hide the + * upload sections entirely. + * + * Only [Form] (and [Pending] after a submit) is reachable from user + * actions in this static shell; the review outcomes arrive with the KYC + * repository in a later entrega and are exercised here via @Preview. + */ +sealed interface KycUiState { + data class Form( + val front: SlotStatus, + val back: SlotStatus, + ) : KycUiState + + data object Pending : KycUiState + + data object Verified : KycUiState + + data object Suspended : KycUiState + + data object Error : KycUiState +} + +/** Upload status of a single ID photo slot. */ +enum class SlotStatus { Empty, Uploading, Uploaded } + +/** Which side of the ID a slot represents. */ +enum class SlotKind { Front, Back } + +/** Number of slots that finished uploading (drives the progress meter). */ +internal fun KycUiState.Form.uploadedCount(): Int = listOf(front, back).count { it == SlotStatus.Uploaded } + +/** Submission is only allowed once both photos are uploaded. */ +internal fun KycUiState.Form.canSubmit(): Boolean = front == SlotStatus.Uploaded && back == SlotStatus.Uploaded 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 e25da69..391e894 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.kyc.KycScreen import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData @@ -115,6 +116,9 @@ fun StackNavHost( }, ) } + composable(Route.Kyc.path) { + KycScreen(onBack = { navController.popBackStack() }) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -186,7 +190,6 @@ private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController private val PLACEHOLDER_ROUTES: List> = listOf( Route.HouseWallet to "House Wallet", - Route.Kyc to "KYC", Route.Coinflip to "Coinflip", Route.Roulette to "Roulette", Route.Crash to "Crash", diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/kyc/KycFormTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/kyc/KycFormTest.kt new file mode 100644 index 0000000..f626488 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/kyc/KycFormTest.kt @@ -0,0 +1,24 @@ +package com.plainstudio.stackcasino.feature.kyc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KycFormTest { + @Test + fun `uploadedCount counts only finished slots`() { + assertEquals(0, KycUiState.Form(SlotStatus.Empty, SlotStatus.Empty).uploadedCount()) + assertEquals(0, KycUiState.Form(SlotStatus.Uploading, SlotStatus.Empty).uploadedCount()) + assertEquals(1, KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Empty).uploadedCount()) + assertEquals(2, KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Uploaded).uploadedCount()) + } + + @Test + fun `canSubmit requires both photos uploaded`() { + assertTrue(KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Uploaded).canSubmit()) + assertFalse(KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Empty).canSubmit()) + assertFalse(KycUiState.Form(SlotStatus.Uploaded, SlotStatus.Uploading).canSubmit()) + assertFalse(KycUiState.Form(SlotStatus.Empty, SlotStatus.Empty).canSubmit()) + } +}