diff --git a/CHANGELOG.md b/CHANGELOG.md index 13576c4..ab39865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Profile screen (cu-15): `ProfileViewModel` maps the signed-in + `AuthRepository.currentUser` into the identity block (name, email, + Google provider, avatar monogram); the P&L summary and game-activity + breakdown render as placeholders until the stats layer ships. Includes + the Loading skeleton, the Error retry card, the settings sheet + (`ModalBottomSheet`), deep links to KYC and the House Wallet, and Sign + Out, which tears down the session and clears the back stack to Login. +- Shared `initialsOf` (avatar monogram) and `GameKey.iconRes` (game + glyph) helpers, extracted from the login, lobby and history screens so + the profile screen reuses them instead of duplicating. - Self-contained HMAC-SHA256 implementation under `cpp_engine/include/Sha256.h`, following FIPS 180-4 (SHA-256) and RFC 2104 (HMAC). Header-only, no external dependencies. diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/profile/ProfileScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/profile/ProfileScreenTest.kt new file mode 100644 index 0000000..7f8f02f --- /dev/null +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/profile/ProfileScreenTest.kt @@ -0,0 +1,84 @@ +package com.plainstudio.stackcasino.feature.profile + +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.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 ProfileScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun success_renders_identity_stats_and_account_actions() { + composeRule.setContent { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Success(previewProfileData()), + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithText("John Doe").assertIsDisplayed() + composeRule.onNodeWithText("john.doe@gmail.com").assertIsDisplayed() + composeRule.onNodeWithText("Net P&L (all-time)").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Verify Identity").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Sign Out").performScrollTo().assertIsDisplayed() + } + + @Test + fun sign_out_row_invokes_the_callback() { + var signedOut = false + composeRule.setContent { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Success(previewProfileData()), + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = { signedOut = true }, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithText("Sign Out").performScrollTo().performClick() + + assertTrue(signedOut) + } + + @Test + fun error_shows_the_retry_card_and_no_identity() { + composeRule.setContent { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Error("Couldn't load your profile."), + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithText("Couldn't load profile").assertIsDisplayed() + composeRule.onNodeWithText("RETRY").assertIsDisplayed() + composeRule.onAllNodesWithText("John Doe").assertCountEquals(0) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreen.kt index d81d433..26a5740 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreen.kt @@ -59,6 +59,7 @@ 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.initialsOf /** * Login screen reproducing the cu-02 mockup (default / returning / @@ -554,20 +555,6 @@ private fun ErrorBanner( // Helpers // --------------------------------------------------------------------------- -/** - * Splits the display name on whitespace and joins the first letter of - * the first two tokens for the avatar tile (e.g. "John Doe" -> "JD"). - * Falls back to the first character when only a single word is present. - */ -private fun initialsOf(name: String): String { - val tokens = name.split(' ').filter { it.isNotBlank() } - return when (tokens.size) { - 0 -> "" - 1 -> tokens[0].first().uppercase() - else -> "${tokens[0].first().uppercase()}${tokens[1].first().uppercase()}" - } -} - /** * Walks the ContextWrapper chain until it lands on an Activity. * Required because Credential Manager's getCredential expects an diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt index 0819f1d..190b26f 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt @@ -42,7 +42,6 @@ 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.R import com.plainstudio.stackcasino.model.GameKey import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.ui.components.EmptyState @@ -50,6 +49,7 @@ import com.plainstudio.stackcasino.ui.components.FilterChip import com.plainstudio.stackcasino.ui.components.FilterChipRow import com.plainstudio.stackcasino.ui.components.StackCard import com.plainstudio.stackcasino.ui.components.gridBackground +import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk @@ -526,15 +526,6 @@ private fun GameKey.label(): String = GameKey.Coinflip -> "Coinflip" } -private fun GameKey.iconRes(): Int = - when (this) { - GameKey.Roulette -> R.drawable.ic_game_roulette - GameKey.Blackjack -> R.drawable.ic_game_blackjack - GameKey.Crash -> R.drawable.ic_game_crash - GameKey.Mines -> R.drawable.ic_game_mines - GameKey.Coinflip -> R.drawable.ic_game_coinflip - } - // --------------------------------------------------------------------------- // Tokens // --------------------------------------------------------------------------- diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt index 23f2a0d..5c53021 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyGamesSection.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SurfaceOutline import com.plainstudio.stackcasino.ui.theme.SurfaceRaised diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt index b27f364..302fb76 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyHeader.kt @@ -29,6 +29,7 @@ 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 com.plainstudio.stackcasino.util.initialsOf /** * Lobby top bar: avatar tile, greeting + display name, and the @@ -123,20 +124,6 @@ private fun NotificationsButton( } } -/** - * Splits the display name on whitespace and joins the first letter of - * the first two tokens for the avatar tile (e.g. "John Doe" -> "JD"). - * Falls back to the first character when only a single word is present. - */ -private fun initialsOf(name: String): String { - val tokens = name.split(' ').filter { it.isNotBlank() } - return when (tokens.size) { - 0 -> "" - 1 -> tokens[0].first().uppercase() - else -> "${tokens[0].first().uppercase()}${tokens[1].first().uppercase()}" - } -} - private val HeaderTopPadding = 24.dp private val HeaderBottomPadding = 16.dp private val AvatarSize = 40.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt index 35ae0ac..88e7602 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyRecentActivity.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.RoundOutcome import com.plainstudio.stackcasino.ui.components.StackCard +import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt index 7ab996e..40308cb 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/lobby/LobbyScreen.kt @@ -241,15 +241,6 @@ private fun GameKey.toRoute(): Route = GameKey.Coinflip -> Route.Coinflip } -internal fun GameKey.iconRes(): Int = - when (this) { - GameKey.Roulette -> R.drawable.ic_game_roulette - GameKey.Blackjack -> R.drawable.ic_game_blackjack - GameKey.Crash -> R.drawable.ic_game_crash - GameKey.Mines -> R.drawable.ic_game_mines - GameKey.Coinflip -> R.drawable.ic_game_coinflip - } - // --------------------------------------------------------------------------- // Tokens shared across the lobby section files via the `internal` modifier. // --------------------------------------------------------------------------- diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt new file mode 100644 index 0000000..fa39740 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt @@ -0,0 +1,183 @@ +package com.plainstudio.stackcasino.feature.profile + +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.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Shield +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.vector.ImageVector +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.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Account actions: verify identity (KYC), house wallet (operator), + * notifications and sign out, followed by the build footer. + */ +@Composable +internal fun ProfileAccountSection( + appVersionLabel: String, + onOpenKyc: () -> Unit, + onOpenHouseWallet: () -> Unit, + onSignOut: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Account", + color = TextMedium, + fontSize = SectionTitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.fillMaxWidth().profileCardSurface()) { + AccountRow( + icon = Icons.Outlined.Shield, + title = "Verify Identity", + subtitle = "Upload DNI · unlock withdrawals > $100", + onClick = onOpenKyc, + ) + Divider() + AccountRow( + icon = Icons.Outlined.AccountBalanceWallet, + title = "House Wallet", + subtitle = "Biometric · Android Keystore", + onClick = onOpenHouseWallet, + badge = "Operator", + ) + Divider() + AccountRow( + icon = Icons.Outlined.Notifications, + title = "Notifications", + subtitle = "Push · deposit, withdrawal, security", + onClick = {}, + ) + Divider() + SignOutRow(onClick = onSignOut) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = appVersionLabel, + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun AccountRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + badge: String? = null, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(RowIconSize), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, color = TextHigh, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + if (badge != null) OperatorBadge(label = badge) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(ChevronSize), + ) + } +} + +@Composable +private fun SignOutRow(onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(CardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Logout, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(RowIconSize), + ) + Text( + text = "Sign Out", + color = SemanticDanger, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun OperatorBadge(label: String) { + Box( + modifier = + Modifier + .background(AccentViolet.copy(alpha = BADGE_BACKGROUND_ALPHA)) + .border(width = 1.dp, color = AccentViolet.copy(alpha = BADGE_BORDER_ALPHA)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = label, + color = AccentViolet, + fontSize = BadgeFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private val SectionTitleFontSize = 11.sp +private val RowIconSize = 18.dp +private val ChevronSize = 14.dp +private val BadgeFontSize = 8.sp +private const val BADGE_BACKGROUND_ALPHA = 0.20f +private const val BADGE_BORDER_ALPHA = 0.40f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt new file mode 100644 index 0000000..77f4021 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt @@ -0,0 +1,176 @@ +package com.plainstudio.stackcasino.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxHeight +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.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.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.ui.iconRes +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +import com.plainstudio.stackcasino.ui.theme.SemanticInfo +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SemanticWarn +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Game activity section: a by-volume breakdown, one row per game with a + * proportion bar colored per game and the per-game net P&L. + */ +@Composable +internal fun ProfileGameActivity(rows: List) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Game Activity", + color = TextMedium, + fontSize = SectionTitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = "By Volume", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.fillMaxWidth().profileCardSurface()) { + rows.forEachIndexed { index, row -> + if (index > 0) Divider() + GameActivityRowItem(row = row) + } + } + } +} + +@Composable +private fun GameActivityRowItem(row: GameActivityRow) { + Column(modifier = Modifier.fillMaxWidth().padding(CardPadding)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GameGlyph(game = row.game) + Column { + Text( + text = gameLabel(row.game), + color = TextHigh, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = row.betsWageredLabel.uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + } + Text( + text = row.pnlLabel, + color = if (row.isPositive) SemanticOk else SemanticDanger, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + style = TabularNums, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + ActivityBar(fillFraction = row.fillFraction, accent = row.accent.color()) + } +} + +@Composable +private fun GameGlyph(game: GameKey) { + Box( + modifier = Modifier.size(GlyphBoxSize).border(width = 1.dp, color = SurfaceOutline), + contentAlignment = Alignment.Center, + ) { + if (game == GameKey.Coinflip) { + Text(text = "x2", color = AccentViolet, fontSize = 10.sp, fontWeight = FontWeight.Bold) + } else { + Icon( + painter = painterResource(game.iconRes()), + contentDescription = null, + tint = AccentViolet, + modifier = Modifier.size(GlyphIconSize), + ) + } + } +} + +@Composable +private fun ActivityBar( + fillFraction: Float, + accent: Color, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(ActivityBarHeight) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline), + ) { + Box(modifier = Modifier.fillMaxHeight().fillMaxWidth(fillFraction).background(accent)) + } +} + +private fun ActivityAccent.color(): Color = + when (this) { + ActivityAccent.Violet -> AccentViolet + ActivityAccent.Warn -> SemanticWarn + ActivityAccent.Info -> SemanticInfo + ActivityAccent.Ok -> SemanticOk + ActivityAccent.Danger -> SemanticDanger + } + +private fun gameLabel(game: GameKey): String = + when (game) { + GameKey.Roulette -> "Roulette" + GameKey.Blackjack -> "Blackjack" + GameKey.Crash -> "Crash" + GameKey.Mines -> "Mines" + GameKey.Coinflip -> "Coinflip" + } + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val SectionTitleFontSize = 11.sp +private val GlyphBoxSize = 32.dp +private val GlyphIconSize = 14.dp +private val ActivityBarHeight = 4.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt new file mode 100644 index 0000000..f2c06d8 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt @@ -0,0 +1,143 @@ +package com.plainstudio.stackcasino.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.SemanticWarn +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Identity card: avatar monogram, name, email, the Google provider line + * and a footer with the verification badge plus the member-since label. + */ +@Composable +internal fun ProfileIdentityCard( + identity: ProfileIdentity, + memberSinceLabel: String, + isVerified: Boolean, +) { + Column(modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + AvatarTile(initials = identity.initials) + Column(modifier = Modifier.weight(1f)) { + Text( + text = identity.displayName, + color = TextHigh, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = identity.email, + color = TextMedium, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + ProviderLine() + } + } + Spacer(modifier = Modifier.height(16.dp)) + Divider() + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + VerificationBadge(isVerified = isVerified) + Text( + text = memberSinceLabel, + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun AvatarTile(initials: String) { + Box( + modifier = Modifier.size(AvatarSize).background(AccentViolet), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun ProviderLine() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_google_g), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(GoogleGlyphSize), + ) + Text( + text = "Google · Gmail", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun VerificationBadge(isVerified: Boolean) { + val accent = if (isVerified) SemanticOk else SemanticWarn + val label = if (isVerified) "Verified" else "Unverified" + Box( + modifier = + Modifier + .background(accent.copy(alpha = BADGE_BACKGROUND_ALPHA)) + .border(width = 1.dp, color = accent.copy(alpha = BADGE_BORDER_ALPHA)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = label, + color = accent, + fontSize = MetaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +private val AvatarSize = 64.dp +private val GoogleGlyphSize = 12.dp +private const val BADGE_BACKGROUND_ALPHA = 0.15f +private const val BADGE_BORDER_ALPHA = 0.40f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt new file mode 100644 index 0000000..a509b43 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt @@ -0,0 +1,71 @@ +package com.plainstudio.stackcasino.feature.profile + +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.components.Skeleton + +/** + * Shimmer placeholder shown while the identity stream resolves. Mirrors + * the success layout (identity card, P&L card, activity list) so the + * content does not jump when the real data arrives. + */ +@Composable +internal fun ProfileLoadingBody(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionGap), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + Row( + modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Skeleton(modifier = Modifier.size(64.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Skeleton(modifier = Modifier.fillMaxWidth(NAME_LINE_FRACTION).height(16.dp)) + Skeleton(modifier = Modifier.fillMaxWidth(EMAIL_LINE_FRACTION).height(12.dp)) + Skeleton(modifier = Modifier.fillMaxWidth(META_LINE_FRACTION).height(8.dp)) + } + } + Column( + modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Skeleton(modifier = Modifier.fillMaxWidth(PNL_LABEL_FRACTION).height(8.dp)) + Skeleton(modifier = Modifier.fillMaxWidth(PNL_AMOUNT_FRACTION).height(28.dp)) + Skeleton(modifier = Modifier.fillMaxWidth().height(6.dp)) + } + Column( + modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(ACTIVITY_ROW_COUNT) { + Skeleton(modifier = Modifier.fillMaxWidth().height(48.dp)) + } + } + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } +} + +// Skeleton line widths as a fraction of the card, tuned to read like the +// real identity / P&L blocks underneath. +private const val NAME_LINE_FRACTION = 0.5f +private const val EMAIL_LINE_FRACTION = 0.7f +private const val META_LINE_FRACTION = 0.3f +private const val PNL_LABEL_FRACTION = 0.4f +private const val PNL_AMOUNT_FRACTION = 0.6f +private const val ACTIVITY_ROW_COUNT = 3 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt new file mode 100644 index 0000000..b34fcb6 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt @@ -0,0 +1,124 @@ +package com.plainstudio.stackcasino.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxHeight +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * All-time profit and loss card: the net figure over a won-vs-wagered + * proportion bar with its legend. + */ +@Composable +internal fun ProfilePnlCard(pnl: ProfilePnl) { + Column(modifier = Modifier.fillMaxWidth().profileCardSurface().padding(CardPadding)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Net P&L (all-time)", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = "${pnl.betsLabel} · ${pnl.winRateLabel}".uppercase(), + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + style = TabularNums, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = pnl.netLabel, + color = if (pnl.isNetNegative) SemanticDanger else SemanticOk, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + Spacer(modifier = Modifier.height(16.dp)) + ProportionBar(wonFraction = pnl.wonFraction) + Spacer(modifier = Modifier.height(8.dp)) + Legend(wonLabel = pnl.wonLabel, wageredLabel = pnl.wageredLabel) + } +} + +@Composable +private fun ProportionBar(wonFraction: Float) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(BarHeight) + .background(SurfaceBase) + .border(width = 1.dp, color = SurfaceOutline), + ) { + Box(modifier = Modifier.fillMaxHeight().weight(wonFraction).background(SemanticOk)) + Box(modifier = Modifier.fillMaxHeight().weight(1f - wonFraction).background(SemanticDanger)) + } +} + +@Composable +private fun Legend( + wonLabel: String, + wageredLabel: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(modifier = Modifier.size(LegendDotSize).background(SemanticOk)) + Text( + text = wonLabel, + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = wageredLabel, + color = TextMedium, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Box(modifier = Modifier.size(LegendDotSize).background(SemanticDanger)) + } + } +} + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val BarHeight = 6.dp +private val LegendDotSize = 8.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePreviewData.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePreviewData.kt new file mode 100644 index 0000000..365c6ee --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePreviewData.kt @@ -0,0 +1,85 @@ +package com.plainstudio.stackcasino.feature.profile + +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.util.initialsOf + +/** + * Static seeds for the profile screen. The P&L and game-activity blocks + * are placeholders (numbers mirror mockup/js/screens/profile.js) until a + * Firestore/Room stats source ships; [ProfileViewModel] attaches them to + * the real signed-in identity, and the @Preview composables use + * [previewProfileData] wholesale. + */ +internal const val MEMBER_SINCE_PLACEHOLDER = "Member since Apr 2026" +internal const val APP_VERSION_LABEL = "Stack · Plain Studio · v0.1.0" + +internal fun placeholderPnl(): ProfilePnl = + ProfilePnl( + netLabel = "-$3,529.50", + isNetNegative = true, + betsLabel = "342 bets", + winRateLabel = "47.2% win rate", + wonLabel = "Won $8,920.50", + wageredLabel = "Wagered $12,450", + wonFraction = 0.717f, + ) + +internal fun placeholderGameActivity(): List = + listOf( + GameActivityRow( + game = GameKey.Crash, + betsWageredLabel = "125 bets · $4,200 wagered", + pnlLabel = "+$850.00", + isPositive = true, + fillFraction = 1.0f, + accent = ActivityAccent.Violet, + ), + GameActivityRow( + game = GameKey.Roulette, + betsWageredLabel = "89 bets · $3,100 wagered", + pnlLabel = "-$320.00", + isPositive = false, + fillFraction = 0.738f, + accent = ActivityAccent.Warn, + ), + GameActivityRow( + game = GameKey.Blackjack, + betsWageredLabel = "67 bets · $2,800 wagered", + pnlLabel = "+$450.00", + isPositive = true, + fillFraction = 0.667f, + accent = ActivityAccent.Info, + ), + GameActivityRow( + game = GameKey.Mines, + betsWageredLabel = "42 bets · $1,600 wagered", + pnlLabel = "+$120.00", + isPositive = true, + fillFraction = 0.381f, + accent = ActivityAccent.Ok, + ), + GameActivityRow( + game = GameKey.Coinflip, + betsWageredLabel = "19 bets · $750 wagered", + pnlLabel = "-$180.00", + isPositive = false, + fillFraction = 0.179f, + accent = ActivityAccent.Danger, + ), + ) + +internal fun previewProfileData(): ProfileData = + ProfileData( + identity = + ProfileIdentity( + displayName = "John Doe", + email = "john.doe@gmail.com", + initials = initialsOf("John Doe"), + photoUrl = null, + ), + pnl = placeholderPnl(), + gameActivity = placeholderGameActivity(), + memberSinceLabel = MEMBER_SINCE_PLACEHOLDER, + isVerified = false, + appVersionLabel = APP_VERSION_LABEL, + ) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt new file mode 100644 index 0000000..2dcf2e7 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt @@ -0,0 +1,297 @@ +package com.plainstudio.stackcasino.feature.profile + +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.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.plainstudio.stackcasino.ui.components.ErrorState +import com.plainstudio.stackcasino.ui.components.gridBackground +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +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 + +/** + * Profile screen reproducing the cu-15 mockup + * (mockup/js/screens/profile.js, success / loading / error states plus + * the settings sheet). + * + * The identity block is sourced from the signed-in user via + * [ProfileViewModel]; the P&L and game-activity stats are placeholders + * until the stats layer ships. Sign Out tears down the session and the + * caller (nav host) clears the back stack back to Login via + * [onSignedOut]. The settings sheet visibility is screen-local UI state. + */ +@Composable +fun ProfileScreen( + onOpenKyc: () -> Unit, + onOpenHouseWallet: () -> Unit, + onSignedOut: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var showSettings by rememberSaveable { mutableStateOf(false) } + + ProfileScreenContent( + state = state, + onOpenSettings = { showSettings = true }, + onOpenKyc = onOpenKyc, + onOpenHouseWallet = onOpenHouseWallet, + onSignOut = { + viewModel.signOut() + onSignedOut() + }, + onRetry = viewModel::refresh, + modifier = modifier, + ) + + if (showSettings) { + ProfileSettingsSheet(onDismiss = { showSettings = false }) + } +} + +@Composable +internal fun ProfileScreenContent( + state: ProfileUiState, + onOpenSettings: () -> Unit, + onOpenKyc: () -> Unit, + onOpenHouseWallet: () -> Unit, + onSignOut: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize().gridBackground()) { + ProfileHeader(onOpenSettings = onOpenSettings) + Divider() + when (state) { + is ProfileUiState.Success -> + ProfileSuccessBody( + data = state.data, + onOpenKyc = onOpenKyc, + onOpenHouseWallet = onOpenHouseWallet, + onSignOut = onSignOut, + modifier = Modifier.weight(1f), + ) + ProfileUiState.Loading -> ProfileLoadingBody(modifier = Modifier.weight(1f)) + is ProfileUiState.Error -> + ProfileErrorBody( + message = state.message, + onRetry = onRetry, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun ProfileHeader(onOpenSettings: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = HeaderTopPadding, + bottom = HeaderBottomPadding, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Profile", + color = TextHigh, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Box( + modifier = + Modifier + .size(SettingsButtonSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onOpenSettings), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = "Settings", + tint = TextHigh, + modifier = Modifier.size(SettingsIconSize), + ) + } + } +} + +@Composable +private fun ProfileSuccessBody( + data: ProfileData, + onOpenKyc: () -> Unit, + onOpenHouseWallet: () -> Unit, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionGap), + verticalArrangement = Arrangement.spacedBy(SectionGap), + ) { + ProfileIdentityCard( + identity = data.identity, + memberSinceLabel = data.memberSinceLabel, + isVerified = data.isVerified, + ) + ProfilePnlCard(pnl = data.pnl) + ProfileGameActivity(rows = data.gameActivity) + ProfileAccountSection( + appVersionLabel = data.appVersionLabel, + onOpenKyc = onOpenKyc, + onOpenHouseWallet = onOpenHouseWallet, + onSignOut = onSignOut, + ) + Spacer(modifier = Modifier.height(BottomScrollPadding)) + } +} + +@Composable +private fun ProfileErrorBody( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + ErrorState( + icon = { + Icon( + imageVector = Icons.Outlined.PersonOutline, + contentDescription = null, + tint = SemanticDanger, + modifier = Modifier.size(ErrorIconSize), + ) + }, + title = "Couldn't load profile", + message = message, + primaryActionLabel = "Retry", + onPrimaryAction = onRetry, + ) + } +} + +// --------------------------------------------------------------------------- +// Shared building blocks +// --------------------------------------------------------------------------- + +/** + * Bordered graphite surface every profile card sits on (identity, P&L, + * activity list, account list). Callers add their own content padding. + */ +internal fun Modifier.profileCardSurface(): Modifier = + background(SurfaceRaised).border(width = 1.dp, color = SurfaceOutline) + +@Composable +internal fun Divider() { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +internal val ScreenHorizontalPadding = 16.dp +internal val SectionGap = 20.dp +internal val CardPadding = 16.dp +internal val TrackedLetterSpacing = 1.2.sp +internal val MetaFontSize = 10.sp +internal val SmallMetaFontSize = 9.sp + +private val HeaderTopPadding = 24.dp +private val HeaderBottomPadding = 16.dp +private val SettingsButtonSize = 36.dp +private val SettingsIconSize = 16.dp +private val ErrorIconSize = 24.dp + +// Shared by the success body and the loading skeleton so both clear the +// bottom navigation bar. +internal val BottomScrollPadding = 96.dp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1500) +@Composable +private fun ProfileScreenSuccessPreview() { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Success(previewProfileData()), + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = {}, + onRetry = {}, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 900) +@Composable +private fun ProfileScreenLoadingPreview() { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Loading, + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = {}, + onRetry = {}, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 900) +@Composable +private fun ProfileScreenErrorPreview() { + StackcasinoTheme { + ProfileScreenContent( + state = ProfileUiState.Error("Couldn't load your profile. Check your connection and try again."), + onOpenSettings = {}, + onOpenKyc = {}, + onOpenHouseWallet = {}, + onSignOut = {}, + onRetry = {}, + ) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt new file mode 100644 index 0000000..d2781e2 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt @@ -0,0 +1,183 @@ +package com.plainstudio.stackcasino.feature.profile + +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.TextStyle +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.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow + +/** + * Settings sheet reached from the profile header gear. The rows mirror + * mockup/js/screens/profile.js; the toggles and the cache-clear action + * are visual stubs for now (persisting settings and wiping the Room + * cache land with the data layer). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ProfileSettingsSheet(onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = SurfaceRaised, + contentColor = TextHigh, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Text( + text = "Settings", + color = TextHigh, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = RowHorizontalPadding, end = RowHorizontalPadding, bottom = 16.dp), + ) + Divider() + ToggleRow( + title = "Push Notifications", + subtitle = "Deposits, withdrawals, security", + isOn = true, + ) + Divider() + ToggleRow( + title = "Hide Balance on Launch", + subtitle = "Start with balance hidden", + isOn = false, + ) + Divider() + ClearCacheRow() + Divider() + AboutBlock() + } + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + isOn: Boolean, +) { + SettingRow { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, color = TextHigh, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = subtitle, color = TextLow, fontSize = SmallMetaFontSize, letterSpacing = TrackedLetterSpacing) + } + ToggleVisual(isOn = isOn) + } +} + +@Composable +private fun ClearCacheRow() { + SettingRow { + Column(modifier = Modifier.weight(1f)) { + Text(text = "Clear Local Cache", color = TextHigh, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Room DB · 4.2 MB", + color = TextLow, + fontSize = SmallMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Text( + text = "Clear", + color = SemanticDanger, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + modifier = Modifier.clickable { /* cache wipe ships with the Room layer */ }, + ) + } +} + +@Composable +private fun AboutBlock() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), + ) { + Text(text = "About", color = TextHigh, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(8.dp)) + AboutLine(label = "Version", value = "0.1.0") + AboutLine(label = "Build", value = "2026.04.17") + AboutLine(label = "Network", value = "Polygon Mainnet") + AboutLine(label = "Engine", value = "C++ / JNI") + } +} + +@Composable +private fun AboutLine( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, color = TextLow, fontSize = 11.sp) + Text(text = value, color = TextLow, fontSize = 11.sp, style = TabularNums) + } +} + +@Composable +private fun SettingRow(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) +} + +@Composable +private fun ToggleVisual(isOn: Boolean) { + val track = + if (isOn) { + Modifier.background(AccentViolet) + } else { + Modifier.background(SurfaceElevated).border(width = 1.dp, color = SurfaceOutline) + } + Box( + modifier = Modifier.size(width = ToggleWidth, height = ToggleHeight).then(track).padding(2.dp), + contentAlignment = if (isOn) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Box(modifier = Modifier.size(ToggleKnobSize).background(if (isOn) Color.White else TextLow)) + } +} + +private val TabularNums = TextStyle(fontFeatureSettings = "tnum") +private val RowHorizontalPadding = 20.dp +private val RowVerticalPadding = 16.dp +private val ToggleWidth = 40.dp +private val ToggleHeight = 24.dp +private val ToggleKnobSize = 20.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileUiState.kt new file mode 100644 index 0000000..30d3a0a --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileUiState.kt @@ -0,0 +1,93 @@ +package com.plainstudio.stackcasino.feature.profile + +import com.plainstudio.stackcasino.model.GameKey + +/** + * UI state for the profile screen. + * + * Mirrors the three states drawn by the mockup + * (mockup/js/screens/profile.js, `data-profile-show="success|loading|error"`): + * + * * [Success] renders the identity card, the P&L summary, the game + * activity list and the account actions, seeded with [ProfileData]. + * * [Loading] renders the shimmer skeleton while the identity stream + * resolves. + * * [Error] renders the centered retry card when the identity stream + * fails. + * + * The identity block is sourced from the real signed-in user; the P&L + * and game-activity numbers are static placeholders until the + * Firestore/Room stats layer lands in the final entrega (same approach + * as Lobby and Wallet). + */ +sealed interface ProfileUiState { + data object Loading : ProfileUiState + + data class Success( + val data: ProfileData, + ) : ProfileUiState + + data class Error( + val message: String, + ) : ProfileUiState +} + +/** + * Everything the success state renders. [identity] is real; the rest is + * placeholder data until the stats layer ships. + */ +data class ProfileData( + val identity: ProfileIdentity, + val pnl: ProfilePnl, + val gameActivity: List, + val memberSinceLabel: String, + val isVerified: Boolean, + val appVersionLabel: String, +) + +/** + * Identity block, projected from the signed-in `AuthUser`. [initials] + * is the avatar monogram; [photoUrl] is carried for the Glide avatar + * that lands with the image-loading card. + */ +data class ProfileIdentity( + val displayName: String, + val email: String, + val initials: String, + val photoUrl: String?, +) + +/** + * All-time profit and loss summary. [wonFraction] is the won-vs-wagered + * ratio that drives the split proportion bar; the labels are + * pre-formatted at the data layer. + */ +data class ProfilePnl( + val netLabel: String, + val isNetNegative: Boolean, + val betsLabel: String, + val winRateLabel: String, + val wonLabel: String, + val wageredLabel: String, + val wonFraction: Float, +) + +/** + * One row in the game activity list. [fillFraction] is the by-volume + * proportion bar width; [accent] colors that bar per game; [isPositive] + * drives only the P&L amount color. + */ +data class GameActivityRow( + val game: GameKey, + val betsWageredLabel: String, + val pnlLabel: String, + val isPositive: Boolean, + val fillFraction: Float, + val accent: ActivityAccent, +) + +/** + * Palette slot used by a game activity bar. Mapped to a concrete theme + * color in the composable so the data layer stays free of UI types. + */ +enum class ActivityAccent { Violet, Warn, Info, Ok, Danger } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt new file mode 100644 index 0000000..86169db --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt @@ -0,0 +1,89 @@ +package com.plainstudio.stackcasino.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.auth.AuthRepository +import com.plainstudio.stackcasino.domain.auth.AuthUser +import com.plainstudio.stackcasino.util.initialsOf +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the profile screen. + * + * Observes [AuthRepository.currentUser] and maps the signed-in user into + * [ProfileUiState.Success], attaching the placeholder P&L and game + * activity stats (the Firestore/Room stats source lands in the final + * entrega). A failure in the identity stream surfaces as + * [ProfileUiState.Error]; [refresh] re-subscribes so the screen's retry + * action works. When the user signs out the stream emits null and the + * VM falls back to [ProfileUiState.Loading] while navigation leaves the + * screen. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class ProfileViewModel + @Inject + constructor( + private val authRepository: AuthRepository, + ) : ViewModel() { + private val retryTrigger = MutableStateFlow(0) + + val uiState: StateFlow = + retryTrigger + .flatMapLatest { + authRepository.currentUser + .map { user -> + if (user != null) ProfileUiState.Success(user.toProfileData()) else ProfileUiState.Loading + }.catch { emit(ProfileUiState.Error(PROFILE_LOAD_ERROR)) } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = ProfileUiState.Loading, + ) + + /** Tears down the session; navigation back to Login is the screen's job. */ + fun signOut() { + viewModelScope.launch { authRepository.signOut() } + } + + /** Re-subscribes to the identity stream after an error. */ + fun refresh() { + retryTrigger.update { it + 1 } + } + + private fun AuthUser.toProfileData(): ProfileData { + val name = displayName ?: FALLBACK_DISPLAY_NAME + return ProfileData( + identity = + ProfileIdentity( + displayName = name, + email = email.orEmpty(), + initials = initialsOf(name), + photoUrl = photoUrl, + ), + pnl = placeholderPnl(), + gameActivity = placeholderGameActivity(), + memberSinceLabel = MEMBER_SINCE_PLACEHOLDER, + isVerified = false, + appVersionLabel = APP_VERSION_LABEL, + ) + } + + private companion object { + const val STOP_TIMEOUT_MILLIS = 5_000L + const val FALLBACK_DISPLAY_NAME = "Player" + const val PROFILE_LOAD_ERROR = + "Couldn't load your profile. Check your connection and try again." + } + } 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 62176b4..bb424b5 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -22,6 +22,7 @@ import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData import com.plainstudio.stackcasino.feature.news.NewsDetailScreen import com.plainstudio.stackcasino.feature.news.NewsScreen +import com.plainstudio.stackcasino.feature.profile.ProfileScreen import com.plainstudio.stackcasino.feature.wallet.WalletScreen import com.plainstudio.stackcasino.feature.wallet.WalletTab import com.plainstudio.stackcasino.feature.wallet.previewWalletData @@ -96,6 +97,22 @@ fun StackNavHost( }, ) } + composable(Route.Profile.path) { + ProfileScreen( + onOpenKyc = { navController.navigate(Route.Kyc.path) { launchSingleTop = true } }, + onOpenHouseWallet = { + navController.navigate(Route.HouseWallet.path) { launchSingleTop = true } + }, + onSignedOut = { + // Clear the whole graph so Back cannot return to an + // authenticated screen after signing out. + navController.navigate(Route.Login.path) { + popUpTo(navController.graph.id) { inclusive = true } + launchSingleTop = true + } + }, + ) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -165,7 +182,6 @@ private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController private val PLACEHOLDER_ROUTES: List> = listOf( Route.HouseWallet to "House Wallet", - Route.Profile to "Profile", Route.Kyc to "KYC", Route.Coinflip to "Coinflip", Route.Roulette to "Roulette", diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/GameKeyIcon.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/GameKeyIcon.kt new file mode 100644 index 0000000..1451292 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/GameKeyIcon.kt @@ -0,0 +1,21 @@ +package com.plainstudio.stackcasino.ui + +import androidx.annotation.DrawableRes +import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.model.GameKey + +/** + * Maps a [GameKey] to its monochrome game glyph in `res/drawable`. + * Shared by every surface that lists games (lobby grid, lobby recent + * activity, history rounds, profile game activity) so the mapping lives + * in one place. + */ +@DrawableRes +internal fun GameKey.iconRes(): Int = + when (this) { + GameKey.Roulette -> R.drawable.ic_game_roulette + GameKey.Blackjack -> R.drawable.ic_game_blackjack + GameKey.Crash -> R.drawable.ic_game_crash + GameKey.Mines -> R.drawable.ic_game_mines + GameKey.Coinflip -> R.drawable.ic_game_coinflip + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/Initials.kt b/app/src/main/java/com/plainstudio/stackcasino/util/Initials.kt new file mode 100644 index 0000000..ec36019 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/Initials.kt @@ -0,0 +1,19 @@ +package com.plainstudio.stackcasino.util + +/** + * Builds the avatar monogram from a display name: the first letter of + * the first two whitespace-separated tokens (e.g. "John Doe" -> "JD"). + * Falls back to the first character for a single-word name and to an + * empty string for a blank name. + * + * Shared by every surface that paints an initials avatar tile (login, + * lobby header, profile). + */ +internal fun initialsOf(name: String): String { + val tokens = name.split(' ').filter { it.isNotBlank() } + return when (tokens.size) { + 0 -> "" + 1 -> tokens[0].first().uppercase() + else -> "${tokens[0].first().uppercase()}${tokens[1].first().uppercase()}" + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt new file mode 100644 index 0000000..268860f --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt @@ -0,0 +1,95 @@ +package com.plainstudio.stackcasino.feature.profile + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.auth.AuthRepository +import com.plainstudio.stackcasino.domain.auth.AuthUser +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ProfileViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val authRepository = mockk() + + private val sampleUser = + AuthUser( + uid = "uid-1", + email = "john.doe@gmail.com", + displayName = "John Doe", + photoUrl = null, + ) + + @Test + fun `maps the signed-in user into a Success state`() = + runTest { + every { authRepository.currentUser } returns flowOf(sampleUser) + + ProfileViewModel(authRepository).uiState.test { + val success = awaitSuccess() + assertEquals("John Doe", success.data.identity.displayName) + assertEquals("john.doe@gmail.com", success.data.identity.email) + assertEquals("JD", success.data.identity.initials) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `falls back to a placeholder name when the user has no display name`() = + runTest { + every { authRepository.currentUser } returns flowOf(sampleUser.copy(displayName = null)) + + ProfileViewModel(authRepository).uiState.test { + val success = awaitSuccess() + assertEquals("Player", success.data.identity.displayName) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `surfaces Error when the identity stream fails`() = + runTest { + every { authRepository.currentUser } returns flow { throw IllegalStateException("boom") } + + ProfileViewModel(authRepository).uiState.test { + var state = awaitItem() + while (state is ProfileUiState.Loading) state = awaitItem() + assertTrue(state is ProfileUiState.Error) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `signOut delegates to the repository`() = + runTest { + every { authRepository.currentUser } returns flowOf(sampleUser) + coEvery { authRepository.signOut() } just Runs + + ProfileViewModel(authRepository).signOut() + + coVerify(exactly = 1) { authRepository.signOut() } + } +} + +/** + * Awaits the first [ProfileUiState.Success], skipping the transient + * Loading frame that the initial stateIn value may surface (or may be + * conflated away depending on dispatch timing). + */ +private suspend fun app.cash.turbine.ReceiveTurbine.awaitSuccess(): ProfileUiState.Success { + var state = awaitItem() + while (state is ProfileUiState.Loading) state = awaitItem() + return state as ProfileUiState.Success +}