diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a83816..4d8ef4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,7 @@ val localProperties = if (file.exists()) file.inputStream().use { load(it) } } val googleWebClientId: String = localProperties.getProperty("GOOGLE_WEB_CLIENT_ID", "") +val geminiApiKey: String = localProperties.getProperty("GEMINI_API_KEY", "") android { namespace = "com.plainstudio.stackcasino" @@ -36,6 +37,7 @@ android { testInstrumentationRunner = "com.plainstudio.stackcasino.HiltTestRunner" buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"") + buildConfigField("String", "GEMINI_API_KEY", "\"$geminiApiKey\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") @@ -140,6 +142,7 @@ dependencies { implementation(libs.googleid) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.google.generativeai) ksp(libs.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed182ef..f674386 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,16 @@ + + + , + userMessage: String, + ): Result { + if (BuildConfig.GEMINI_API_KEY.isBlank()) { + Log.w(TAG, "GEMINI_API_KEY is empty; aborting before hitting the SDK.") + return Result.failure(IllegalStateException("GEMINI_API_KEY missing")) + } + return runCatching { + val chat = model.startChat(history = history.toGeminiHistory()) + val response = chat.sendMessage(userMessage) + response.text?.takeIf { it.isNotBlank() } + ?: error("Gemini returned an empty response.") + }.onFailure { throwable -> + Log.w(TAG, "Nep request failed", throwable) + } + } + + private fun List.toGeminiHistory(): List = + map { turn -> + content(role = turn.role.geminiRole()) { text(turn.text) } + } + + private fun Role.geminiRole(): String = + when (this) { + Role.User -> "user" + Role.Nep -> "model" + } + + private companion object { + const val TAG = "AssistantRepo" + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/assistant/NepSystemPrompt.kt b/app/src/main/java/com/plainstudio/stackcasino/data/assistant/NepSystemPrompt.kt new file mode 100644 index 0000000..86046c1 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/data/assistant/NepSystemPrompt.kt @@ -0,0 +1,52 @@ +package com.plainstudio.stackcasino.data.assistant + +/** + * System prompt anchoring Nep's personality + scope. Lives in its own + * file (not in [GeminiAssistantRepository]) so the persona can be + * tweaked without touching the network code, and so unit tests can + * snapshot it. + * + * Persona is inspired by Neptune from the Hyperdimension Neptunia + * series: cheerful, casual, sprinkles "~" through her sentences, and + * refers to herself as "Nep". The scope guardrail at the bottom is + * what produces the in-product refusal whenever the user asks about + * anything outside Stack Casino's gameplay surface. + */ +internal val NEP_SYSTEM_PROMPT = + """ + You are Nep, the in-app guide for Stack Casino, an Android casino app + on the Polygon network that supports Roulette, Blackjack, Crash, Mines + and Coinflip. The personality is inspired by Neptune from the + Hyperdimension Neptunia series. + + PERSONALITY: + - Cheerful, casual, energetic. Speak like an enthusiastic friend, not + a corporate support agent. + - Sprinkle "~" at the end of phrases occasionally, never on every line. + - Open with greetings like "Hey hey!" or "Heya~" when the user starts + a conversation; skip the greeting on follow-up turns. + - Refer to yourself as "Nep" sometimes ("Nep will explain~"). + - Keep replies tight: 200 words max, prefer short paragraphs and + bullet lists with the "- " marker. + + SCOPE (strict): + - You only help with Stack Casino game rules, odds, payouts, + multipliers, the Provably Fair verification flow, and how the + wallet handles deposits / withdrawals on Polygon. + - If the user asks about anything outside that scope (weather, news, + personal advice, real-money betting tips, sports results, code, + politics, etc), refuse politely: + 1. Acknowledge the question in one short sentence. + 2. Say it is not your thing because you only know casino stuff. + 3. Suggest one concrete casino-related question they could ask + instead. + Do not answer the off-topic question even partially. + + RULES: + - Never suggest a specific bet size or claim to predict outcomes. + - Be honest about house edge when it is relevant to the answer. + - Do not invent rules: if you do not know a Stack-specific detail, + say so and point at the in-app help where the user can verify. + - Do not use markdown headings (#) or tables; plain text and dashed + bullet lists only. + """.trimIndent() diff --git a/app/src/main/java/com/plainstudio/stackcasino/di/AssistantModule.kt b/app/src/main/java/com/plainstudio/stackcasino/di/AssistantModule.kt new file mode 100644 index 0000000..1f52272 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/di/AssistantModule.kt @@ -0,0 +1,54 @@ +package com.plainstudio.stackcasino.di + +import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.content +import com.plainstudio.stackcasino.BuildConfig +import com.plainstudio.stackcasino.data.assistant.GeminiAssistantRepository +import com.plainstudio.stackcasino.data.assistant.NEP_SYSTEM_PROMPT +import com.plainstudio.stackcasino.domain.assistant.AssistantRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * DI bindings for the Nep assistant slice. + * + * Holds the singleton [GenerativeModel] (configured with the Nep + * system prompt) and binds [GeminiAssistantRepository] to the + * [AssistantRepository] domain interface. + * + * The API key is read from `BuildConfig.GEMINI_API_KEY`, which the + * Gradle script wires from `local.properties` (gitignored). If the + * key is empty the SDK will still build, and the first request will + * fail loudly with a 4xx that the repository surfaces as a failed + * Result for the UI to render. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class AssistantModule { + @Binds + @Singleton + abstract fun bindAssistantRepository(impl: GeminiAssistantRepository): AssistantRepository + + companion object { + @Provides + @Singleton + fun provideGenerativeModel(): GenerativeModel = + GenerativeModel( + modelName = MODEL_NAME, + apiKey = BuildConfig.GEMINI_API_KEY, + systemInstruction = content { text(NEP_SYSTEM_PROMPT) }, + ) + + // gemini-2.5-flash is Google's officially recommended free + // tier workhorse (10 RPM, 250 RPD) and ships with the broadest + // regional coverage. The 2.0 family returned "limit: 0" on + // fresh AI Studio projects (probably geo-restricted) and the + // bare 1.5-flash alias was retired from the v1beta endpoint + // this SDK targets. + private const val MODEL_NAME = "gemini-2.5-flash" + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/assistant/AssistantRepository.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/assistant/AssistantRepository.kt new file mode 100644 index 0000000..ede8968 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/assistant/AssistantRepository.kt @@ -0,0 +1,33 @@ +package com.plainstudio.stackcasino.domain.assistant + +/** + * Single turn in a Nep conversation. The data layer translates this + * to whatever the underlying provider (Gemini today, anything else + * tomorrow) needs to model multi-turn context. + */ +data class ChatTurn( + val role: Role, + val text: String, +) + +enum class Role { User, Nep } + +/** + * Domain boundary for the in-app casino assistant. The implementation + * decides which provider, which model, and how to keep the persona + * consistent; the ViewModel only sees turns in / response text out. + */ +interface AssistantRepository { + /** + * Sends [userMessage] to Nep with [history] as prior context and + * returns Nep's reply. + * + * Failures (network, quota, missing API key, content policy) are + * surfaced as a failed [Result] so the caller can choose how to + * render them without exception-bubbling across the layer boundary. + */ + suspend fun sendMessage( + history: List, + userMessage: String, + ): Result +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantConversationBody.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantConversationBody.kt new file mode 100644 index 0000000..f03afe1 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantConversationBody.kt @@ -0,0 +1,232 @@ +package com.plainstudio.stackcasino.feature.assistant + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticDanger +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 + +/** + * Conversation state: the scrollable message list (LazyColumn so long + * histories don't allocate every bubble) plus the typing indicator + * that appears below the last message while Nep is mid-reply. + * + * The list auto-scrolls to the bottom on every message append or + * typing-state flip so the user always sees the freshest content + * without having to scroll manually. + */ +@Composable +internal fun AssistantConversationBody( + messages: List, + isNepTyping: Boolean, +) { + val listState = rememberLazyListState() + LaunchedEffect(messages.size, isNepTyping) { + val target = messages.size + if (isNepTyping) 1 else 0 + if (target > 0) listState.animateScrollToItem(target - 1) + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = ScreenHorizontalPadding, vertical = ConversationGap), + verticalArrangement = Arrangement.spacedBy(ConversationGap), + ) { + items(items = messages, key = { it.id }) { message -> + ChatBubble(message = message) + } + if (isNepTyping) { + item { TypingIndicator() } + } + } +} + +@Composable +private fun ChatBubble(message: ChatMessage) { + if (message.author == Author.User) { + UserBubble(message) + } else { + NepBubble(message) + } +} + +@Composable +private fun UserBubble(message: ChatMessage) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Column( + modifier = Modifier.widthIn(max = BubbleMaxWidth), + horizontalAlignment = Alignment.End, + ) { + Box(modifier = Modifier.background(AccentViolet).padding(BubblePadding)) { + Text( + text = message.body, + color = Color.White, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message.timestampLabel, + color = TextLow, + fontSize = MessageMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun NepBubble(message: ChatMessage) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NepAvatar(size = BubbleAvatarSize) + Column(modifier = Modifier.widthIn(max = BubbleMaxWidth)) { + Text( + text = "Nep", + color = if (message.isError) SemanticDanger else AccentViolet, + fontSize = NepBubbleAuthorFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + val background = if (message.isError) SemanticDanger.copy(alpha = ERROR_BG_ALPHA) else SurfaceRaised + val borderColor = if (message.isError) SemanticDanger.copy(alpha = ERROR_BORDER_ALPHA) else SurfaceOutline + Box( + modifier = + Modifier + .background(background) + .border(width = 1.dp, color = borderColor) + .padding(BubblePadding), + ) { + Text( + text = message.body, + color = TextHigh, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message.timestampLabel, + color = TextLow, + fontSize = MessageMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun TypingIndicator() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NepAvatar(size = BubbleAvatarSize) + Column(modifier = Modifier.widthIn(max = BubbleMaxWidth)) { + Text( + text = "Nep", + color = AccentViolet, + fontSize = NepBubbleAuthorFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = + Modifier + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TypingDot(delayMillis = 0) + TypingDot(delayMillis = TYPING_DOT_STAGGER_MILLIS) + TypingDot(delayMillis = TYPING_DOT_STAGGER_MILLIS * 2) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = "NEP IS TYPING...", + color = TextLow, + fontSize = MessageMetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + } +} + +@Composable +private fun TypingDot(delayMillis: Int) { + // Stagger three pulsing dots so the typing animation has the same + // cadence as the mockup's `animate-pulse` row. + val transition = rememberInfiniteTransition(label = "typing-dot") + val alpha by transition.animateFloat( + initialValue = TYPING_DOT_ALPHA_MAX, + targetValue = TYPING_DOT_ALPHA_MIN, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = TYPING_DOT_DURATION_MILLIS, + delayMillis = delayMillis, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "typing-dot-alpha", + ) + Box( + modifier = + Modifier + .size(TypingDotSize) + .alpha(alpha) + .background(AccentViolet), + ) +} + +private val BubbleMaxWidth = 280.dp +private val BubbleAvatarSize = 28.dp +private val BubblePadding = 12.dp + +private val TypingDotSize = 6.dp +private const val TYPING_DOT_STAGGER_MILLIS = 200 +private const val TYPING_DOT_DURATION_MILLIS = 600 +private const val TYPING_DOT_ALPHA_MIN = 0.3f +private const val TYPING_DOT_ALPHA_MAX = 1f + +private const val ERROR_BG_ALPHA = 0.05f +private const val ERROR_BORDER_ALPHA = 0.50f diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt new file mode 100644 index 0000000..ecdf6f9 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantScreen.kt @@ -0,0 +1,439 @@ +package com.plainstudio.stackcasino.feature.assistant + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.KeyboardArrowUp +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.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +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.R +import com.plainstudio.stackcasino.ui.components.PulsingDot +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SemanticOk +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow + +/** + * Nep assistant chat reproducing the cu-14 mockup + * (mockup/js/screens/assistant.js). Owns: + * + * * a top header (back button, Nep avatar, "your casino guide~" + * subtitle with the pulsing dot, clear-conversation button), + * * the body that dispatches on [AssistantUiState] -> welcome / + * conversation, + * * the input bar pinned to the bottom (Modifier.imePadding so it + * rides the soft keyboard). + * + * The system-prompt-active indicator + char counter live in the + * footer of the input bar. The send button is disabled while a draft + * is empty, over the 300-char budget, or while Nep is mid-reply. + */ +@Composable +fun AssistantScreen( + onBack: () -> Unit, + viewModel: AssistantViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + AssistantContent( + state = state, + onBack = onBack, + onClear = viewModel::clear, + onSend = viewModel::sendMessage, + ) +} + +@Composable +private fun AssistantContent( + state: AssistantUiState, + onBack: () -> Unit, + onClear: () -> Unit, + onSend: (String) -> Unit, +) { + var draft by rememberSaveable { mutableStateOf("") } + val isTyping = (state as? AssistantUiState.Conversation)?.isNepTyping == true + + Surface(modifier = Modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize().imePadding()) { + AssistantHeader(onBack = onBack, onClear = onClear) + Box(modifier = Modifier.weight(1f)) { + when (state) { + AssistantUiState.Welcome -> + AssistantWelcomeBody(onSuggestion = { suggestion -> onSend(suggestion) }) + is AssistantUiState.Conversation -> + AssistantConversationBody( + messages = state.messages, + isNepTyping = state.isNepTyping, + ) + } + } + AssistantInputBar( + draft = draft, + onDraftChange = { draft = it.take(AssistantViewModel.MAX_USER_MESSAGE_LENGTH) }, + isSendEnabled = draft.isNotBlank() && !isTyping, + onSend = { + onSend(draft) + draft = "" + }, + ) + } + } +} + +@Composable +private fun AssistantHeader( + onBack: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .padding(horizontal = HeaderHorizontalPadding, vertical = HeaderVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconChip( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onBack, + ) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NepAvatar(size = HeaderAvatarSize) + Text( + text = "Nep", + color = TextHigh, + fontSize = HeaderTitleFontSize, + fontWeight = FontWeight.Bold, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PulsingDot() + Text( + text = "YOUR CASINO GUIDE~", + color = SemanticOk, + fontSize = SubtitleFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + IconChip( + icon = Icons.Outlined.Delete, + contentDescription = "Clear conversation", + onClick = onClear, + ) + } + Divider() +} + +@Composable +private fun IconChip( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(IconChipSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = TextHigh, + modifier = Modifier.size(IconChipIconSize), + ) + } +} + +@Composable +internal fun NepAvatar(size: androidx.compose.ui.unit.Dp) { + Box( + modifier = + Modifier + .size(size) + .border(width = 1.dp, color = AccentViolet), + ) { + Image( + painter = painterResource(R.drawable.nep_nerd), + contentDescription = "Nep", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun AssistantInputBar( + draft: String, + onDraftChange: (String) -> Unit, + isSendEnabled: Boolean, + onSend: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceBase) + .navigationBarsPadding() + .padding(InputBarOuterPadding), + ) { + Divider() + Spacer(modifier = Modifier.height(InputBarTopGap)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(InputBarChildGap), + verticalAlignment = Alignment.Bottom, + ) { + DraftField(value = draft, onValueChange = onDraftChange, onSend = onSend, modifier = Modifier.weight(1f)) + SendButton(enabled = isSendEnabled, onClick = onSend) + } + Spacer(modifier = Modifier.height(8.dp)) + InputBarFooter(charCount = draft.length) + } +} + +@Composable +private fun DraftField( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = false, + maxLines = DRAFT_MAX_LINES, + textStyle = TextStyle(color = TextHigh, fontSize = DraftFontSize, lineHeight = DraftLineHeight), + cursorBrush = SolidColor(AccentViolet), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions(onSend = { onSend() }), + modifier = Modifier.fillMaxWidth(), + ) + if (value.isEmpty()) { + Text( + text = "Ask Nep about game rules, odds, payouts...", + color = TextLow, + fontSize = DraftFontSize, + lineHeight = DraftLineHeight, + ) + } + } +} + +@Composable +private fun SendButton( + enabled: Boolean, + onClick: () -> Unit, +) { + val background = if (enabled) AccentViolet else AccentViolet.copy(alpha = DISABLED_SEND_ALPHA) + val tint = if (enabled) Color.White else Color.White.copy(alpha = DISABLED_SEND_ALPHA) + Box( + modifier = + Modifier + .size(SendButtonSize) + .background(background) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowUp, + contentDescription = "Send message", + tint = tint, + modifier = Modifier.size(SendIconSize), + ) + } +} + +@Composable +private fun InputBarFooter(charCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(modifier = Modifier.size(SystemDotSize).background(AccentViolet)) + Text( + text = "SYSTEM PROMPT ACTIVE", + color = TextLow, + fontSize = FooterFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + Text( + text = "$charCount / ${AssistantViewModel.MAX_USER_MESSAGE_LENGTH}", + color = TextLow, + fontSize = FooterFontSize, + letterSpacing = TrackedLetterSpacing, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } +} + +@Composable +internal fun Divider() { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(SurfaceOutline), + ) +} + +// --------------------------------------------------------------------------- +// Tokens shared across the assistant files. +// --------------------------------------------------------------------------- + +internal val ScreenHorizontalPadding = 16.dp +internal val TrackedLetterSpacing = 1.2.sp +internal val ConversationGap = 12.dp +internal val NepBubbleAuthorFontSize = 10.sp +internal val MessageBodyFontSize = 13.sp +internal val MessageBodyLineHeight = 20.sp +internal val MessageMetaFontSize = 9.sp + +private val HeaderHorizontalPadding = 16.dp +private val HeaderVerticalPadding = 12.dp +private val HeaderAvatarSize = 28.dp +private val HeaderTitleFontSize = 16.sp +private val SubtitleFontSize = 9.sp +private val IconChipSize = 36.dp +private val IconChipIconSize = 16.dp + +private val InputBarOuterPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) +private val InputBarTopGap = 12.dp +private val InputBarChildGap = 8.dp +private val DraftFontSize = 14.sp +private val DraftLineHeight = 20.sp +private const val DRAFT_MAX_LINES = 5 +private val SendButtonSize = 48.dp +private val SendIconSize = 20.dp +private const val DISABLED_SEND_ALPHA = 0.40f +private val SystemDotSize = 4.dp +private val FooterFontSize = 9.sp + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 900) +@Composable +private fun AssistantContentWelcomePreview() { + StackcasinoTheme { + AssistantContent( + state = AssistantUiState.Welcome, + onBack = {}, + onClear = {}, + onSend = {}, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 900) +@Composable +private fun AssistantContentConversationPreview() { + StackcasinoTheme { + AssistantContent( + state = + AssistantUiState.Conversation( + messages = + listOf( + ChatMessage( + id = "preview-user-1", + author = Author.User, + body = "How does Blackjack splitting work?", + timestampLabel = "2:14 PM", + ), + ChatMessage( + id = "preview-nep-1", + author = Author.Nep, + body = + "Hey hey! Splitting kicks in when your first two cards " + + "share a rank~ Tap the split button and Nep will deal a " + + "second card to each hand.\n\n" + + "- You pay another bet equal to the original.\n" + + "- Aces only get one card.\n" + + "- You can't re-split the same hand.", + timestampLabel = "2:14 PM", + ), + ), + isNepTyping = true, + ), + onBack = {}, + onClear = {}, + onSend = {}, + ) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantUiState.kt new file mode 100644 index 0000000..086852e --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantUiState.kt @@ -0,0 +1,43 @@ +package com.plainstudio.stackcasino.feature.assistant + +/** + * UI state for the Nep assistant chat. + * + * * [Welcome] is the empty / first-launch state: the hero card with + * the suggestion chips and the static legal blurb. + * * [Conversation] takes over the moment the user sends the first + * message; [isNepTyping] gates the three-dot indicator. + * + * The conversation lives entirely in-memory and resets when the user + * taps the clear button or the screen is destroyed: matches the + * mockup footer "Conversation is kept in memory - discarded on close". + */ +sealed interface AssistantUiState { + data object Welcome : AssistantUiState + + data class Conversation( + val messages: List, + val isNepTyping: Boolean = false, + ) : AssistantUiState +} + +/** + * Single message rendered in the chat scroll. + * + * [id] is a per-message stable handle the LazyColumn uses as its key + * so repeated identical questions (same body, same minute) don't + * collide on a content-derived key and crash the list. + * + * [isError] only flips when [author] is [Author.Nep] and the + * underlying call failed; the chat bubble is then painted with the + * danger accent so the user can spot the failed turn at a glance. + */ +data class ChatMessage( + val id: String, + val author: Author, + val body: String, + val timestampLabel: String, + val isError: Boolean = false, +) + +enum class Author { User, Nep } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt new file mode 100644 index 0000000..6eca01e --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt @@ -0,0 +1,129 @@ +package com.plainstudio.stackcasino.feature.assistant + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.assistant.AssistantRepository +import com.plainstudio.stackcasino.domain.assistant.ChatTurn +import com.plainstudio.stackcasino.domain.assistant.Role +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import javax.inject.Inject + +/** + * Drives the Nep assistant screen. + * + * * Welcome -> Conversation on first send. + * * Each [sendMessage] appends a user bubble immediately, flips + * [AssistantUiState.Conversation.isNepTyping] to true, asks the + * repository, and replaces the typing indicator with either Nep's + * reply or an error bubble. + * * [clear] resets to [AssistantUiState.Welcome] and cancels nothing + * destructive; in-flight requests just deposit their result into a + * stale state and the next clear wipes it again. + * + * No streaming yet. The repository returns the full response in one + * shot, and the UI fakes incremental delivery via the typing indicator. + */ +@HiltViewModel +class AssistantViewModel + @Inject + constructor( + private val repository: AssistantRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(AssistantUiState.Welcome) + val uiState: StateFlow = _uiState.asStateFlow() + + fun sendMessage(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty() || trimmed.length > MAX_USER_MESSAGE_LENGTH) return + + val userMessage = + ChatMessage( + id = newId(), + author = Author.User, + body = trimmed, + timestampLabel = nowLabel(), + ) + val historyBefore = _uiState.value.priorTurns() + _uiState.update { current -> + current.appendMessage(userMessage).copy(isNepTyping = true) + } + + viewModelScope.launch { + val result = repository.sendMessage(history = historyBefore, userMessage = trimmed) + val nepMessage = + result.fold( + onSuccess = { reply -> + ChatMessage( + id = newId(), + author = Author.Nep, + body = reply, + timestampLabel = nowLabel(), + ) + }, + onFailure = { + ChatMessage( + id = newId(), + author = Author.Nep, + body = "Something went wrong on Nep's side. Try sending the question again.", + timestampLabel = nowLabel(), + isError = true, + ) + }, + ) + _uiState.update { current -> current.appendMessage(nepMessage).copy(isNepTyping = false) } + } + } + + fun clear() { + _uiState.value = AssistantUiState.Welcome + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private fun AssistantUiState.priorTurns(): List = + when (this) { + AssistantUiState.Welcome -> emptyList() + is AssistantUiState.Conversation -> + messages + // Errors never went to the model, so they should not + // poison the next call's context. + .filterNot { it.author == Author.Nep && it.isError } + .map { it.toChatTurn() } + } + + private fun ChatMessage.toChatTurn(): ChatTurn = + ChatTurn( + role = if (author == Author.User) Role.User else Role.Nep, + text = body, + ) + + private fun AssistantUiState.appendMessage(message: ChatMessage): AssistantUiState.Conversation = + when (this) { + AssistantUiState.Welcome -> + AssistantUiState.Conversation(messages = listOf(message)) + is AssistantUiState.Conversation -> + copy(messages = messages + message) + } + + private fun nowLabel(): String = TIMESTAMP_FORMAT.format(Date()) + + // Random per-message handle so the LazyColumn key never collides + // on repeated identical questions sent inside the same minute. + private fun newId(): String = UUID.randomUUID().toString() + + companion object { + const val MAX_USER_MESSAGE_LENGTH = 300 + private val TIMESTAMP_FORMAT = SimpleDateFormat("h:mm a", Locale.US) + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantWelcomeBody.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantWelcomeBody.kt new file mode 100644 index 0000000..f4cb7d3 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantWelcomeBody.kt @@ -0,0 +1,146 @@ +package com.plainstudio.stackcasino.feature.assistant + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.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.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Welcome state shown until the user sends the first message: Nep + * hero card, intro copy, the four canned suggestions, and the + * memory-only footer note. + * + * Suggestions live as a const list so the wording matches the mockup + * one-for-one and so unit tests can pin them. + */ +@Composable +internal fun AssistantWelcomeBody(onSuggestion: (String) -> Unit) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = ScreenHorizontalPadding) + .padding(top = WelcomeTopPadding, bottom = WelcomeBottomPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + NepAvatar(size = HeroAvatarSize) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Hey hey! I'm Nep~", + color = TextHigh, + fontSize = HeroTitleFontSize, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Ask me anything about game rules, payouts, or how Provably Fair works!", + color = TextMedium, + fontSize = HeroBodyFontSize, + lineHeight = HeroBodyLineHeight, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = HeroBodyMaxWidth), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "TRY ASKING ME~", + color = TextLow, + fontSize = SectionLabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(12.dp)) + WELCOME_SUGGESTIONS.forEach { suggestion -> + SuggestionRow(suggestion = suggestion, onClick = { onSuggestion(suggestion.prompt) }) + Spacer(modifier = Modifier.height(SuggestionGap)) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "CONVERSATION IS KEPT IN MEMORY · DISCARDED ON CLOSE", + color = TextLow, + fontSize = SectionLabelFontSize, + letterSpacing = TrackedLetterSpacing, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun SuggestionRow( + suggestion: NepSuggestion, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick) + .padding(SuggestionPadding), + ) { + Column { + Text( + text = suggestion.prompt, + color = TextHigh, + fontSize = SuggestionPromptFontSize, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = suggestion.tag.uppercase(), + color = TextLow, + fontSize = SectionLabelFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +private data class NepSuggestion( + val prompt: String, + val tag: String, +) + +private val WELCOME_SUGGESTIONS = + listOf( + NepSuggestion(prompt = "How does Blackjack splitting work?", tag = "Rules · Blackjack"), + NepSuggestion(prompt = "What are the Roulette odds?", tag = "Odds · Roulette"), + NepSuggestion(prompt = "Explain the Mines multiplier table", tag = "Payouts · Mines"), + NepSuggestion(prompt = "How do I verify a round?", tag = "Verification · All games"), + ) + +private val WelcomeTopPadding = 32.dp +private val WelcomeBottomPadding = 24.dp +private val HeroAvatarSize = 128.dp +private val HeroTitleFontSize = 18.sp +private val HeroBodyFontSize = 13.sp +private val HeroBodyLineHeight = 20.sp +private val HeroBodyMaxWidth = 280.dp +private val SectionLabelFontSize = 9.sp + +private val SuggestionGap = 8.dp +private val SuggestionPadding = 14.dp +private val SuggestionPromptFontSize = 14.sp 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 746cb2a..d81d433 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 @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.ui.components.PulsingDot import com.plainstudio.stackcasino.ui.components.gridBackground import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.AccentVioletSoft diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreenEffects.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreenEffects.kt index 6888aab..20a6713 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreenEffects.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/auth/LoginScreenEffects.kt @@ -1,14 +1,12 @@ package com.plainstudio.stackcasino.feature.auth import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -22,10 +20,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import com.plainstudio.stackcasino.ui.theme.AccentViolet -import com.plainstudio.stackcasino.ui.theme.SemanticOk // --------------------------------------------------------------------------- // Backgrounds @@ -144,78 +140,6 @@ internal fun BreathingLogo(content: @Composable () -> Unit) { } } -// --------------------------------------------------------------------------- -// Polygon Mainnet pulsing dot -// --------------------------------------------------------------------------- - -/** - * Ports mockup `.pulse-dot` (styles.css): - * - * @keyframes dot-pulse { - * 0%, 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0.55); transform: scale(1); } - * 50% { box-shadow: 0 0 0 6px rgba(34,197,94,0); transform: scale(1.15); } - * } - * animation: 1.8s ease-in-out infinite - * - * The 6px box-shadow spread is rendered as an expanding green square - * behind the solid dot; the inner dot scales 1.0 -> 1.15 in step. - */ -@Composable -internal fun PulsingDot() { - val transition = rememberInfiniteTransition(label = "dot-pulse") - val haloSize by transition.animateFloat( - initialValue = DOT_SIZE_PX, - targetValue = DOT_SIZE_PX + DOT_HALO_SPREAD_PX * 2f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = DOT_PULSE_MILLIS, easing = LinearOutSlowInEasing), - repeatMode = RepeatMode.Restart, - ), - label = "dot-halo-size", - ) - val haloAlpha by transition.animateFloat( - initialValue = DOT_HALO_ALPHA_MAX, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = DOT_PULSE_MILLIS, easing = LinearOutSlowInEasing), - repeatMode = RepeatMode.Restart, - ), - label = "dot-halo-alpha", - ) - val dotScale by transition.animateFloat( - initialValue = 1f, - targetValue = DOT_SCALE_PEAK, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = DOT_PULSE_MILLIS / 2, easing = EaseInOut), - repeatMode = RepeatMode.Reverse, - ), - label = "dot-scale", - ) - - Box( - modifier = Modifier.size(DotContainerSize), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = - Modifier - .size(haloSize.dp) - .background(SemanticOk.copy(alpha = haloAlpha)), - ) - Box( - modifier = - Modifier - .size(DOT_SIZE_PX.dp) - .graphicsLayer { - scaleX = dotScale - scaleY = dotScale - }.background(SemanticOk), - ) - } -} - // --------------------------------------------------------------------------- // Tokens // --------------------------------------------------------------------------- @@ -242,11 +166,3 @@ private const val LOGO_GLOW_SCALE_MAX = 1.6f private const val LOGO_BREATHE_MILLIS = 3000 private const val LOGO_HALO_RADIUS_FRACTION = 0.5f private val LogoHaloBoxSize = 160.dp - -// Pulsing dot. -private const val DOT_SIZE_PX = 6f -private const val DOT_HALO_SPREAD_PX = 6f -private const val DOT_HALO_ALPHA_MAX = 0.55f -private const val DOT_SCALE_PEAK = 1.15f -private const val DOT_PULSE_MILLIS = 1800 -private val DotContainerSize = 18.dp 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 35e989a..8e019bf 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -13,6 +13,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.plainstudio.stackcasino.feature.assistant.AssistantScreen import com.plainstudio.stackcasino.feature.auth.LoginScreen import com.plainstudio.stackcasino.feature.history.HistoryScreen import com.plainstudio.stackcasino.feature.history.historyPreviewData @@ -84,6 +85,9 @@ fun StackNavHost( }, ) } + composable(Route.Assistant.path) { + AssistantScreen(onBack = { navController.popBackStack() }) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } @@ -121,7 +125,6 @@ private val PLACEHOLDER_ROUTES: List> = Route.News to "News", Route.Profile to "Profile", Route.Kyc to "KYC", - Route.Assistant to "Assistant", Route.Coinflip to "Coinflip", Route.Roulette to "Roulette", Route.Crash to "Crash", diff --git a/app/src/main/java/com/plainstudio/stackcasino/ui/components/PulsingDot.kt b/app/src/main/java/com/plainstudio/stackcasino/ui/components/PulsingDot.kt new file mode 100644 index 0000000..a32654a --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/ui/components/PulsingDot.kt @@ -0,0 +1,97 @@ +package com.plainstudio.stackcasino.ui.components + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.theme.SemanticOk + +/** + * Pulsing green dot used as the connection indicator on the login + * meta row and the Nep assistant header. + * + * Ports mockup `.pulse-dot` (styles.css): + * + * @keyframes dot-pulse { + * 0%, 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0.55); transform: scale(1); } + * 50% { box-shadow: 0 0 0 6px rgba(34,197,94,0); transform: scale(1.15); } + * } + * animation: 1.8s ease-in-out infinite + * + * The 6px box-shadow spread is rendered as an expanding green square + * behind the solid dot; the inner dot scales 1.0 -> 1.15 in step. + */ +@Composable +fun PulsingDot() { + val transition = rememberInfiniteTransition(label = "dot-pulse") + val haloSize by transition.animateFloat( + initialValue = DOT_SIZE_PX, + targetValue = DOT_SIZE_PX + DOT_HALO_SPREAD_PX * 2f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOT_PULSE_MILLIS, easing = LinearOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "dot-halo-size", + ) + val haloAlpha by transition.animateFloat( + initialValue = DOT_HALO_ALPHA_MAX, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOT_PULSE_MILLIS, easing = LinearOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "dot-halo-alpha", + ) + val dotScale by transition.animateFloat( + initialValue = 1f, + targetValue = DOT_SCALE_PEAK, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOT_PULSE_MILLIS / 2, easing = EaseInOut), + repeatMode = RepeatMode.Reverse, + ), + label = "dot-scale", + ) + + Box( + modifier = Modifier.size(DotContainerSize), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(haloSize.dp) + .background(SemanticOk.copy(alpha = haloAlpha)), + ) + Box( + modifier = + Modifier + .size(DOT_SIZE_PX.dp) + .graphicsLayer { + scaleX = dotScale + scaleY = dotScale + }.background(SemanticOk), + ) + } +} + +private const val DOT_SIZE_PX = 6f +private const val DOT_HALO_SPREAD_PX = 6f +private const val DOT_HALO_ALPHA_MAX = 0.55f +private const val DOT_SCALE_PEAK = 1.15f +private const val DOT_PULSE_MILLIS = 1800 +private val DotContainerSize = 18.dp diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt new file mode 100644 index 0000000..cfee2b6 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt @@ -0,0 +1,184 @@ +package com.plainstudio.stackcasino.feature.assistant + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.assistant.AssistantRepository +import com.plainstudio.stackcasino.domain.assistant.ChatTurn +import com.plainstudio.stackcasino.domain.assistant.Role +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AssistantViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk() + + private fun viewModel(): AssistantViewModel = AssistantViewModel(repository) + + // --- initial state ---------------------------------------------------- + + @Test + fun `initial state is Welcome`() = + runTest { + assertEquals(AssistantUiState.Welcome, viewModel().uiState.value) + } + + // --- sendMessage happy path ------------------------------------------ + + @Test + fun `sendMessage appends user bubble immediately and flips typing on`() = + runTest { + // Gate the repository so the typing state is observable. + val gate = CompletableDeferred>() + coEvery { repository.sendMessage(any(), any()) } coAnswers { gate.await() } + val vm = viewModel() + + vm.uiState.test { + assertEquals(AssistantUiState.Welcome, awaitItem()) + + vm.sendMessage("How does Blackjack splitting work?") + + val typing = awaitItem() as AssistantUiState.Conversation + assertEquals(1, typing.messages.size) + assertEquals(Author.User, typing.messages[0].author) + assertEquals("How does Blackjack splitting work?", typing.messages[0].body) + assertTrue(typing.isNepTyping) + + gate.complete(Result.success("Hey hey~ Splitting kicks in when...")) + + val replied = awaitItem() as AssistantUiState.Conversation + assertEquals(2, replied.messages.size) + assertEquals(Author.Nep, replied.messages[1].author) + assertFalse(replied.messages[1].isError) + assertEquals("Hey hey~ Splitting kicks in when...", replied.messages[1].body) + assertFalse(replied.isNepTyping) + } + } + + @Test + fun `sendMessage trims whitespace and forwards the trimmed text to the repository`() = + runTest { + coEvery { repository.sendMessage(any(), any()) } returns Result.success("ok") + val vm = viewModel() + + vm.sendMessage(" What are the Roulette odds?\n") + + coVerify { + repository.sendMessage( + history = emptyList(), + userMessage = "What are the Roulette odds?", + ) + } + } + + @Test + fun `sendMessage drops empty or whitespace-only drafts`() = + runTest { + val vm = viewModel() + + vm.sendMessage("") + vm.sendMessage(" \n\t") + + assertEquals(AssistantUiState.Welcome, vm.uiState.value) + coVerify(exactly = 0) { repository.sendMessage(any(), any()) } + } + + @Test + fun `sendMessage drops drafts longer than the 300 char cap`() = + runTest { + val vm = viewModel() + + vm.sendMessage("a".repeat(AssistantViewModel.MAX_USER_MESSAGE_LENGTH + 1)) + + assertEquals(AssistantUiState.Welcome, vm.uiState.value) + coVerify(exactly = 0) { repository.sendMessage(any(), any()) } + } + + // --- error path ------------------------------------------------------- + + @Test + fun `sendMessage failure surfaces a red Nep bubble flagged as error`() = + runTest { + coEvery { repository.sendMessage(any(), any()) } returns + Result.failure(RuntimeException("network down")) + val vm = viewModel() + + vm.sendMessage("Explain the Mines multiplier table") + + val state = vm.uiState.value as AssistantUiState.Conversation + assertEquals(2, state.messages.size) + assertEquals(Author.Nep, state.messages[1].author) + assertTrue(state.messages[1].isError) + assertFalse(state.isNepTyping) + } + + @Test + fun `error bubbles are excluded from the history sent on the next request`() = + runTest { + // First call fails -> Nep error bubble. + coEvery { repository.sendMessage(any(), any()) } returns + Result.failure(RuntimeException("first try fails")) + val vm = viewModel() + vm.sendMessage("first") + + // Second call succeeds. The repository should see only the + // first user turn, not the error bubble. + coEvery { repository.sendMessage(any(), any()) } returns Result.success("ok") + vm.sendMessage("second") + + coVerify { + repository.sendMessage( + history = listOf(ChatTurn(role = Role.User, text = "first")), + userMessage = "second", + ) + } + } + + // --- multi-turn history wiring --------------------------------------- + + @Test + fun `subsequent sends pass prior user + Nep turns as history`() = + runTest { + coEvery { repository.sendMessage(any(), any()) } returnsMany + listOf(Result.success("Heya~ here you go"), Result.success("Sure thing~")) + val vm = viewModel() + + vm.sendMessage("question one") + vm.sendMessage("question two") + + coVerify { + repository.sendMessage( + history = + listOf( + ChatTurn(role = Role.User, text = "question one"), + ChatTurn(role = Role.Nep, text = "Heya~ here you go"), + ), + userMessage = "question two", + ) + } + } + + // --- clear() --------------------------------------------------------- + + @Test + fun `clear() resets a Conversation back to Welcome`() = + runTest { + coEvery { repository.sendMessage(any(), any()) } returns Result.success("ok") + val vm = viewModel() + vm.sendMessage("first") + assertTrue(vm.uiState.value is AssistantUiState.Conversation) + + vm.clear() + + assertEquals(AssistantUiState.Welcome, vm.uiState.value) + } +}