From 8c8d8c2881c19605c2393a778f71dd3cf44e6812 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:38:49 -0300 Subject: [PATCH 1/2] refactor(ui): extract shared initialsOf and GameKey.iconRes helpers `initialsOf` was duplicated in LoginScreen and the lobby header, and the `GameKey -> drawable` mapping in both the lobby and history screens. With the profile screen about to need both, pull them into single homes (`util/Initials.kt` and `ui/GameKeyIcon.kt`) and route the existing call sites through them. No behavior change; pure deduplication. --- .../stackcasino/feature/auth/LoginScreen.kt | 15 +------------ .../feature/history/HistoryScreen.kt | 11 +--------- .../feature/lobby/LobbyGamesSection.kt | 1 + .../stackcasino/feature/lobby/LobbyHeader.kt | 15 +------------ .../feature/lobby/LobbyRecentActivity.kt | 1 + .../stackcasino/feature/lobby/LobbyScreen.kt | 9 -------- .../plainstudio/stackcasino/ui/GameKeyIcon.kt | 21 +++++++++++++++++++ .../plainstudio/stackcasino/util/Initials.kt | 19 +++++++++++++++++ 8 files changed, 45 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/ui/GameKeyIcon.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/util/Initials.kt 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/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()}" + } +} From 0046e317d86e9e69b3d32a2bf458d18c446a091b Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:38:49 -0300 Subject: [PATCH 2/2] feat(profile): port the cu-15 profile screen on top of AuthRepository ProfileViewModel observes AuthRepository.currentUser and maps the signed-in user into the identity block (name, email, Google provider, avatar monogram); the P&L summary and game-activity breakdown render as static placeholders until the Firestore/Room stats layer lands. The screen covers all three states (Success, Loading skeleton, Error retry), the settings sheet (ModalBottomSheet, visual stub), deep links to KYC and the House Wallet, and Sign Out, which tears down the session and lets the nav host clear the back stack to Login. Replaces the Profile placeholder in StackNavHost. Per-section composables live in their own files to keep each focused. Tests: ProfileViewModelTest (Success mapping, name fallback, Error on a failing stream, signOut delegation) and ProfileScreenTest (the three states plus the Sign Out callback). --- CHANGELOG.md | 10 + .../feature/profile/ProfileScreenTest.kt | 84 +++++ .../feature/profile/ProfileAccountSection.kt | 183 +++++++++++ .../feature/profile/ProfileGameActivity.kt | 176 +++++++++++ .../feature/profile/ProfileIdentityCard.kt | 143 +++++++++ .../feature/profile/ProfileLoading.kt | 71 +++++ .../feature/profile/ProfilePnlCard.kt | 124 ++++++++ .../feature/profile/ProfilePreviewData.kt | 85 +++++ .../feature/profile/ProfileScreen.kt | 297 ++++++++++++++++++ .../feature/profile/ProfileSettingsSheet.kt | 183 +++++++++++ .../feature/profile/ProfileUiState.kt | 93 ++++++ .../feature/profile/ProfileViewModel.kt | 89 ++++++ .../stackcasino/navigation/StackNavHost.kt | 18 +- .../feature/profile/ProfileViewModelTest.kt | 95 ++++++ 14 files changed, 1650 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/plainstudio/stackcasino/feature/profile/ProfileScreenTest.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileAccountSection.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileGameActivity.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileIdentityCard.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileLoading.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePnlCard.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfilePreviewData.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileScreen.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileSettingsSheet.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileUiState.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModel.kt create mode 100644 app/src/test/java/com/plainstudio/stackcasino/feature/profile/ProfileViewModelTest.kt 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/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/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 +}