“Who are the top scorers this season?”
Old app: ❌ hardcoded endpointsjust knows
New app: ✅ AI that
Yeah… we’re doing that.
I turned my Ask NHL screen into a real AI-powered assistant using Google’s Gemini API — and it took way less code than you think 👀
Let’s build it together 🛠️
Most sports apps do this:
But users don’t think in endpoints… they think in questions:
👉 “Who’s the best goalie right now?”
👉 “Who leads in penalty minutes?”
👉 “What happened in today’s games?”
So I said… screw it.
Let’s let AI handle it.
We send a user’s question → to Gemini → get a clean NHL answer back.
That’s it.
But the 🔑 is how you structure it so it’s:
If your prompt sucks… your AI sucks 🤷♂️
Here’s mine:
private fun buildPrompt(question: String): String = """ You are the "Ask NHL" assistant inside an NHL fan app. Answer the user's hockey question directly and concisely (2-4 sentences max). If the question isn't about hockey or the NHL, politely say you can only help with NHL topics. Question: $question""".trimIndent()
Here’s the full request flow (with Retry/Backoff strategy — this is a must!):
/** * Talks to Gemini to answer free-text NHL questions from the "Ask NHL" screen. */class AskNhlRepository @Inject constructor( private val geminiApi: GeminiApi,) { /** * Sends [question] to Gemini and returns the generated answer text. * Wrapped in a Result so the ViewModel can cleanly show an error state * instead of crashing or leaking a raw exception into the UI layer. * * Retries transient failures (rate limits, 5xx, network blips) with * exponential backoff before giving up — Gemini free-tier flash throws * 503 "overloaded" fairly often, so one failed attempt isn't treated * as a real failure yet. */ suspend fun askQuestion(question: String): Result<String> { return try { val answerText = withRetry { val request = GeminiRequest( contents = listOf( GeminiContent(parts = listOf(GeminiPart(text = buildPrompt(question)))) ) ) val response = geminiApi.generateContent( apiKey = BuildConfig.GEMINI_API_KEY, request = request, ) // Gemini nests the answer text pretty deep: candidates[0].content.parts[0].text response.candidates ?.firstOrNull() ?.content ?.parts ?.firstOrNull() ?.text } if (answerText.isNullOrBlank()) { Result.failure(IllegalStateException("Gemini returned an empty response")) } else { Result.success(answerText.trim()) } } catch (e: Exception) { // Anything that escapes withRetry (a non-retryable HTTP error, // or retries exhausted) lands here as a Result.failure instead // of propagating up and crashing the caller. Result.failure(e) } } /** * Runs [block] up to [maxAttempts] times with exponential backoff * between failures. * * maxAttempts is 2 (not 3): with a 20s per-attempt timeout (see * NetworkModule), 2 attempts caps the worst-case user wait around ~40s * plus a small backoff delay. A 3rd attempt rarely rescues a request * that's failed twice and just makes the user wait longer for the * same likely outcome — not worth it for a chat-style UI on MVP. * * Only retries error types that are likely transient — a 401 or a * malformed request will fail identically every time, so those are * rethrown immediately instead of wasting an attempt on them. */ private suspend fun <T> withRetry( maxAttempts: Int = 2, initialDelayMs: Long = 1_000, maxDelayMs: Long = 8_000, block: suspend () -> T, ): T { var lastError: Throwable? = null repeat(maxAttempts) { attempt -> try { // Success — return immediately, skipping any remaining attempts. return block() } catch (e: HttpException) { // A 429 with a *daily* quota ID means retrying is pointless — // the limit doesn't reset for hours, not for a backoff delay. // Read the error body once here and bail immediately instead // of burning an attempt (and the user's time) on a retry that // cannot succeed. if (e.code() == 429 && e.isDailyQuotaExhausted()) { throw e } val retryable = e.code() in setOf(429, 500, 502, 503, 504) if (!retryable) throw e lastError = e } catch (e: IOException) { // No connection / timeout / DNS failure — always transient, // always worth a retry. lastError = e } // Exponential backoff: 1s, 2s, ... capped at maxDelayMs. // Jitter (random extra delay up to 25% of backoff) spreads out // retries from multiple concurrent requests so they don't all // retry in lockstep and cause another spike. val backoff = (initialDelayMs * 2.0.pow(attempt)) .toLong() .coerceAtMost(maxDelayMs) val jitter = Random.nextLong(0, backoff / 4 + 1) delay(backoff + jitter) } throw lastError ?: IllegalStateException("Gemini request failed with no captured error") } /** * Wraps the raw user question with instructions so Gemini stays * on-topic and keeps answers short enough to fit the UI. */ private fun buildPrompt(question: String): String = """ You are the "Ask NHL" assistant inside an NHL fan app. Answer the user's hockey question directly and concisely (2-4 sentences max). If the question isn't about hockey or the NHL, politely say you can only help with NHL topics. Question: $question """.trimIndent()}/** * Checks whether a 429's error body indicates the *daily* quota (RPD request per minute) was * exhausted, as opposed to a per-minute rate limit (RPM), which IS worth * retrying. Google's error body includes a "quotaId" string that contains * "PerDay" for daily quota violations — that's the signal we key off of. * errorBody() can only be read once per HttpException, so this reads and * discards it; callers shouldn't try to read the body again afterward. */private fun HttpException.isDailyQuotaExhausted(): Boolean { val body = response()?.errorBody()?.string() ?: return false return body.contains("PerDay", ignoreCase = true)}/** * Converts a failure from askQuestion() into copy that's safe to show a * user. Lives near the repository rather than in the ViewModel, since * "what does this error mean to a user" is closer to a networking concern * than a UI-state concern. */fun Throwable.toAskNhlUserMessage(): String = when (this) { is HttpException -> when { code() == 429 && isDailyQuotaExhausted() -> "Ask NHL has hit its free daily limit — check back tomorrow!" code() == 503 || code() == 429 -> "NHL brain is a little overloaded right now — give it a sec and try again." code() == 401 || code() == 403 -> "Something's wrong on our end, not you. We're on it." else -> "Couldn't get an answer for that one. Try rephrasing?" } is IOException -> "Looks like you're offline — check your connection and try again." else -> "Something went sideways. Try again?"}
/** * Retrofit interface describing the Gemini REST endpoint. * Retrofit generates the actual HTTP implementation of this interface at * runtime — you never implement it yourself, just declare the shape of the * request/response. * * Docs: https://ai.google.dev/api/generate-content */interface GeminiApi { /** * @POST marks this as a POST request. The {model} in the URL is filled * in by @Path("model") below — Retrofit substitutes it before sending. * @Header x-goog-api-key sends the API key in the request header for * secure authentication, as required by the Gemini REST API. */ @POST("v1beta/models/{model}:generateContent") suspend fun generateContent( // Switched from gemini-3.5-flash (20 RPD on free tier) to // gemini-3.1-flash-lite, which carries a much more generous free-tier // quota on this project — 500 RPD / 15 RPM vs. 20 RPD / 5 RPM. // Confirmed via live AI Studio rate-limit dashboard, not assumed. @Header("x-goog-api-key") apiKey: String, @Path("model") model: String = "gemini-3.1-flash-lite", @Body request: GeminiRequest, ): GeminiResponse}/** * Top-level request payload sent to Gemini. * @JsonClass(generateAdapter = true) tells Moshi to generate JSON * serialization code for this class at compile time — fast, no reflection. * Make sure to -keep your models in proguard for R8 Minification purposes. */@JsonClass(generateAdapter = true)data class GeminiRequest( // Gemini expects the prompt wrapped in a list of "turns" to support // multi-turn conversations. We only ever send a single turn. val contents: List<GeminiContent>, val generationConfig: GeminiGenerationConfig = GeminiGenerationConfig(),)/** * A single turn in the conversation. */@JsonClass(generateAdapter = true)data class GeminiContent( // A turn is split into "parts" (text, image, etc). We only ever send // one text part. val parts: List<GeminiPart>, // "user" = our prompt. Gemini also accepts "model" for prior AI // replies in real multi-turn chat — we're not doing that, so this is // always "user". val role: String = "user",)/** * A single chunk of content within a turn — just the raw prompt text. */@JsonClass(generateAdapter = true)data class GeminiPart( val text: String,)/** * Controls HOW Gemini generates its response (not what it's asked). * * - temperature: randomness/creativity (0.0 = deterministic, 1.0 = very * random). Kept low since we want factual, consistent hockey answers. * - maxOutputTokens: hard cap on response length. With thinking disabled * (see thinkingConfig below), this budget is now spent entirely on the * visible answer, so 400 comfortably covers a 2-4 sentence response. * - thinkingConfig: disables Gemini's internal reasoning pass. Without * this, gemini-3.5-flash (a "thinking" model) spends a chunk of * maxOutputTokens on invisible reasoning before writing the actual * answer — in testing this ate 382 of our 400 token budget, leaving * only 14 tokens for the real response and cutting it off mid-sentence * (finishReason: MAX_TOKENS). Simple factual Q&A like "who won X in Y" * doesn't need deep reasoning, so turning it off is free speed + a * meaningful token/cost savings — worth it for MVP on free tier. */@JsonClass(generateAdapter = true)data class GeminiGenerationConfig( val temperature: Double = 0.4, @Json(name = "maxOutputTokens") val maxOutputTokens: Int = 400, val responseMimeType: String = "text/plain", val thinkingConfig: GeminiThinkingConfig = GeminiThinkingConfig(),)/** * thinkingBudget = max tokens the model may spend on internal reasoning * before producing the visible answer. 0 disables thinking entirely. */@JsonClass(generateAdapter = true)data class GeminiThinkingConfig( @Json(name = "thinkingBudget") val thinkingBudget: Int = 0,)/** * Top-level response from Gemini. */@JsonClass(generateAdapter = true)data class GeminiResponse( // Nullable + defaulted because a failed/blocked request can come back // with no candidates at all (e.g. safety filter tripped). "candidates" // is a list because Gemini can return multiple response variants — we // only ever look at the first one. val candidates: List<GeminiCandidate>? = null,)/** * A single generated response option. */@JsonClass(generateAdapter = true)data class GeminiCandidate( // Mirrors the request's Content/Part shape. val content: GeminiContent? = null, // Why generation stopped: "STOP" (normal), "MAX_TOKENS" (cut off), // "SAFETY" (blocked), etc. Not branched on yet, but useful later if // you want different handling for truncated vs. blocked vs. success. @Json(name = "finishReason") val finishReason: String? = null,)
fun onQuestionSubmitted(question: String) { if (question.isBlank()) return // Start cycling "thinking" messages immediately, before the network // call even starts — this also sets the initial state. startThinkingTicker() viewModelScope.launch { geminiRepository.askQuestion(question).fold( onSuccess = { answer -> // Stop the ticker now that we have a real result — // otherwise it'd keep overwriting uiState every 500ms // even after we've already set Success. thinkingTickerJob?.cancel() _uiState.value = AskNhlUiState.Success(question, answer) }, onFailure = { throwable -> thinkingTickerJob?.cancel() // toAskNhlUserMessage() converts the raw exception (HTTP // code, IOException, etc) into copy that's safe to show // a user — e.g. a 503 becomes "overloaded, try again" // instead of a stack trace or raw error string. _uiState.value = AskNhlUiState.Error(throwable.toAskNhlUserMessage()) }, ) } }
We’re not just building AI… we’re building experience.
Simple. Clean. No overengineering.
@Composablefun AskNhlScreen(navController: NavController, viewModel: AskNhlViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsState() var inputText by remember { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current fun submit() { if (inputText.isNotBlank()) { viewModel.onQuestionSubmitted(inputText) keyboardController?.hide() focusManager.clearFocus() } } fun onPromptSelected(prompt: String) { inputText = prompt viewModel.onQuestionSubmitted(prompt) } NhlMainScaffold( topBar = { MultiWidgetOnClickToolBar( handleBackNavigation = { navController.popBackStack() }, title = "ASK NHL (Beta)", actions = {}, ) }, navController = navController, bottomBarType = BottomBarType.AD_BANNER, ) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) .imePadding(), ) { LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { when (val state = uiState) { is AskNhlUiState.Idle -> { item { Text( text = "WHERE DO YOU\nWANT TO START?", fontFamily = FontFamily(Font(R.font.nhlitalic)), fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 32.sp, modifier = Modifier.padding(top = 26.dp), ) Spacer(Modifier.height(16.dp)) } items(viewModel.suggestedPrompts) { prompt -> PromptChip(prompt = prompt, onClick = { onPromptSelected(prompt) }) } } is AskNhlUiState. -> { item { CircularProgressIndicator() } } is AskNhlUiState.Success -> { item { Spacer(Modifier.height(16.dp)) Text(text = state.question, style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(8.dp)) Text(text = state.answer, style = MaterialTheme.typography.titleLarge) } } is AskNhlUiState.Error -> { item { Text(text = "Something went wrong: ${state.message}") } } } } NhlOutlinedText( value = inputText, onValueChange = { inputText = it }, label = "Ask about the NHL", trailingIcon = { if (inputText.isNotBlank()) { TextButton(onClick = { submit() }) { Text(text = "Go", color = Color.Black) } } }, modifier = Modifier.padding(all = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { submit() }), shape = RoundedCornerShape(28.dp), ) } }}@Composableprivate fun PromptChip(prompt: String, onClick: () -> Unit) { SuggestionChip( onClick = onClick, label = { Text( text = prompt, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(all = 8.dp), ) }, shape = CircleShape, border = SuggestionChipDefaults.suggestionChipBorder( enabled = true, borderWidth = 1.dp, ), colors = SuggestionChipDefaults.suggestionChipColors( containerColor = Color.Transparent, ), ) Spacer(Modifier.height(12.dp))}
Let the AI shine ✨
This isn’t just a feature… it’s a shift in how we build apps.
Instead of:
“What endpoints do I need?”
Start asking:
“What questions do my users want answered?”
If you’re building Android apps and NOT experimenting with AI yet…
You’re already behind 👀🔥
Grab the beta/MVP on GooglePlay. A sleek Android experience crafted with:
👨👨👦👦 Join the fun Android group on ➜ LinkedIn
Best,
RAFA ROZAY 🍾
🧠🔥 I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps 🤯🏒 was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.