From b7897562a8f27d08bfc2f72971bff03693b09ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:48:20 +0000 Subject: [PATCH 1/9] feat: let the intent-capture overlay be cancelled (back button + hardware back) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the only way out of the 'HOW LONG THIS TIME?' overlay was granting a session — there was no way to back out and leave the blocked app alone. Added a visible '\xe2\x86\x90 BACK' affordance plus hardware/gesture back-key handling (the window is focusable for the excuse field, but nothing consumed KEYCODE_BACK before this). Cancelling mirrors the existing 'left without granting' path (SessionStateMachine.onAppLeft's INTENT_PENDING -> IDLE case) and also sends the user home, since dismissing the overlay alone would just reveal the still-blocked app underneath with nothing granted. --- .../core/watcher/IntentOverlayController.kt | 61 ++++++++++++++++++- .../bonked/core/watcher/WatcherService.kt | 44 ++++++++----- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 07d6a13..76126e2 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -1,6 +1,7 @@ package com.arkarizdev.bonked.core.watcher import android.content.Context +import android.content.Intent import android.graphics.Color import android.graphics.PixelFormat import android.os.Handler @@ -8,6 +9,7 @@ import android.os.Looper import android.text.InputFilter import android.util.Log import android.view.Gravity +import android.view.KeyEvent import android.view.View import android.view.WindowManager import android.widget.Button @@ -75,10 +77,11 @@ class IntentOverlayController(private val context: Context) { budgetMin: Int, usedMinToday: Int, onGrant: (minutes: Int, intentText: String?) -> Unit, + onCancel: () -> Unit, ) { if (shownForPkg == pkg) return shownForPkg = pkg // set immediately so a second poll tick can't double-post - mainHandler.post { showOnMainThread(pkg, eventTs, budgetMin, usedMinToday, onGrant) } + mainHandler.post { showOnMainThread(pkg, eventTs, budgetMin, usedMinToday, onGrant, onCancel) } } private fun showOnMainThread( @@ -87,6 +90,7 @@ class IntentOverlayController(private val context: Context) { budgetMin: Int, usedMinToday: Int, onGrant: (minutes: Int, intentText: String?) -> Unit, + onCancel: () -> Unit, ) { removeCurrentViewOnMainThread() // NOT dismissViewOnMainThread() — see its doc comment; this was T-106's live-caught flicker bug, fixed here too for the same reason @@ -99,6 +103,32 @@ class IntentOverlayController(private val context: Context) { setPadding(dp(24), dp(48), dp(24), dp(32)) } + // Cancelling backs out exactly like SessionStateMachine.onAppLeft's + // INTENT_PENDING -> IDLE case already treats "left without + // granting" — no session was ever created (grant() is what + // creates the Room row), so there's nothing to finalize. Also + // sends the user home: dismissing the overlay alone would just + // reveal the still-blocked app sitting underneath with nothing + // granted, which defeats the point of backing out of it. + fun cancel() { + Log.i(TAG, "CANCEL pkg=$pkg") + onCancel() + dismissViewOnMainThread() + goToHomeScreen() + } + + root.addView(TextView(context).apply { + text = "← BACK" + setTextColor(COLOR_GRAY) + textSize = 14f + setTypeface(typeface, android.graphics.Typeface.BOLD) + isClickable = true + isFocusable = true + setOnClickListener { cancel() } + }, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dp(12) }) + root.addView(TextView(context).apply { text = "HOW LONG THIS TIME?" setTextColor(COLOR_INK) @@ -202,8 +232,23 @@ class IntentOverlayController(private val context: Context) { gravity = Gravity.TOP } + // Hardware/gesture back cancels the same way the "← BACK" button + // does, instead of doing nothing — this window is focusable (the + // excuse field needs real keyboard input) so it does receive key + // events, but nothing consumed KEYCODE_BACK before this. + root.isFocusableInTouchMode = true + root.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + cancel() + true + } else { + false + } + } + windowManager.addView(root, lp) overlayView = root + root.requestFocus() // shownForPkg was already set synchronously in show() and is no // longer touched by removeCurrentViewOnMainThread() above — no // need to re-assert it here (it previously masked the same bug @@ -255,6 +300,20 @@ class IntentOverlayController(private val context: Context) { overlayView = null } + /** Same pattern as RoastOverlayController's helper of the same name — used by cancel() so backing out actually leaves the blocked app instead of just clearing the overlay on top of it. */ + private fun goToHomeScreen() { + try { + context.startActivity( + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } catch (t: Exception) { + Log.w(TAG, "goToHomeScreen failed", t) + } + } + private fun chipBackground(selected: Boolean) = android.graphics.drawable.GradientDrawable().apply { setColor(if (selected) COLOR_YELLOW else COLOR_PAPER) setStroke(dp(3), COLOR_INK) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/WatcherService.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/WatcherService.kt index d31c641..0732f5e 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/WatcherService.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/WatcherService.kt @@ -347,22 +347,38 @@ class WatcherService : Service() { // silently letting the two drift apart with no feedback. val budgetMin = db.watchedAppDao().findByPkg(pkg)?.budgetMin ?: 0 val usedMinToday = WatcherCore.usedMinutesTodayFor(applicationContext, pkg, eventTs) - intentOverlay.show(pkg, eventTs, budgetMin, usedMinToday) { minutes, intentText -> - val now = System.currentTimeMillis() - val ok = sessionStateMachine.grant(pkg, minutes, intentText, now) - Log.i(TAG, "grant pkg=$pkg minutes=$minutes ok=$ok") - if (ok) { - serviceScope.launch { - try { - persistActiveSessions() - logGrantEvent(kind = "GRANT", pkg = pkg, minutes = minutes, extensionsSoFar = 0, hasIntentText = !intentText.isNullOrBlank(), now = now) - precomputeRoast(pkg, intentText, grantedMin = minutes, extensionsSoFar = 0) - } catch (t: Throwable) { - Log.e(TAG, "persist/precompute after grant failed", t) + intentOverlay.show( + pkg = pkg, + eventTs = eventTs, + budgetMin = budgetMin, + usedMinToday = usedMinToday, + onGrant = { minutes, intentText -> + val now = System.currentTimeMillis() + val ok = sessionStateMachine.grant(pkg, minutes, intentText, now) + Log.i(TAG, "grant pkg=$pkg minutes=$minutes ok=$ok") + if (ok) { + serviceScope.launch { + try { + persistActiveSessions() + logGrantEvent(kind = "GRANT", pkg = pkg, minutes = minutes, extensionsSoFar = 0, hasIntentText = !intentText.isNullOrBlank(), now = now) + precomputeRoast(pkg, intentText, grantedMin = minutes, extensionsSoFar = 0) + } catch (t: Throwable) { + Log.e(TAG, "persist/precompute after grant failed", t) + } } } - } - } + }, + onCancel = { + // Mirrors the existing "user switched away without + // granting" path (onAppLeft's INTENT_PENDING -> IDLE case) + // — backing out via the overlay's own back button/hardware + // back is the same abandonment, just triggered from inside + // the overlay instead of by a foreground-app change event. + val now = System.currentTimeMillis() + sessionStateMachine.onAppLeft(pkg, now) + Log.i(TAG, "intent overlay cancelled pkg=$pkg") + }, + ) } /** From c87a34b60f0751e2f7f850a555185a4ff558ffb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:58:34 +0000 Subject: [PATCH 2/9] feat: add roasting-toned excuse chips to the intent overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a horizontal row of pre-written self-roasting excuse chips above the free-text excuse field. Tapping a chip fills the field (still editable after); typing a custom excuse works as before. Either path means whatever ends up in the field is the user roasting themselves before the session even starts, without making the field a hard requirement. Chip copy is placeholder pending a proper copywriting pass — see the prompt in the PR description. --- .../core/watcher/IntentOverlayController.kt | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 76126e2..3d685e9 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -6,7 +6,9 @@ import android.graphics.Color import android.graphics.PixelFormat import android.os.Handler import android.os.Looper +import android.text.Editable import android.text.InputFilter +import android.text.TextWatcher import android.util.Log import android.view.Gravity import android.view.KeyEvent @@ -14,6 +16,7 @@ import android.view.View import android.view.WindowManager import android.widget.Button import android.widget.EditText +import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView @@ -41,6 +44,19 @@ class IntentOverlayController(private val context: Context) { private val DURATION_OPTIONS = listOf(5, 10, 15, 30) private const val DEFAULT_MINUTES = 10 + // T-104 excuse chips: pre-written self-roasting excuses (TONE_GUIDE.md + // rules apply — action, not person). PLACEHOLDER COPY — swap for the + // real pack once written; see the copywriting prompt in the PR + // description for how these were briefed. + private val EXCUSE_CHIPS = listOf( + "Just checking one thing.", + "Five minutes, tops.", + "Research purposes only.", + "Emotional support scrolling.", + "I have a plan. Trust me.", + "No reason. Just vibes.", + ) + private const val COLOR_INK = 0xFF0D0D0D.toInt() private const val COLOR_PAPER = 0xFFFFFFFF.toInt() private const val COLOR_YELLOW = 0xFFFFE600.toInt() @@ -192,15 +208,75 @@ class IntentOverlayController(private val context: Context) { ).apply { bottomMargin = dp(12) }) refreshBudgetWarning() // reflects the DEFAULT_MINUTES chip pre-selected above + root.addView(TextView(context).apply { + text = "WHY, THOUGH? (PICK ONE OR WRITE YOUR OWN)" + setTextColor(COLOR_GRAY) + textSize = 11f + letterSpacing = 0.08f + setTypeface(typeface, android.graphics.Typeface.BOLD) + }, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dp(8) }) + + // Nudges the user into a roast either way, without a hard + // requirement: tapping a chip fills the field with a pre-written + // self-roasting excuse (still editable after), and typing a custom + // one is fair game too — either path means whatever ends up in + // this field is the user roasting themselves, on the record, + // before the session even starts. + val excuseChipViews = mutableMapOf() + var suppressChipSync = false + var excuseFieldRef: EditText? = null // assigned once excuseField is built below; chip taps only fire after that + val excuseChipRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + for ((i, excuse) in EXCUSE_CHIPS.withIndex()) { + val chip = TextView(context).apply { + text = excuse + setTextColor(COLOR_INK) + textSize = 12f + setPadding(dp(12), dp(8), dp(12), dp(8)) + background = chipBackground(false) + setOnClickListener { + suppressChipSync = true + excuseFieldRef?.setText(excuse) + excuseFieldRef?.setSelection(excuse.length) + suppressChipSync = false + excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == excuse) } + } + } + excuseChipViews[excuse] = chip + val params = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT) + if (i > 0) params.marginStart = dp(8) + excuseChipRow.addView(chip, params) + } + root.addView(HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + addView(excuseChipRow) + }, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dp(12) }) + val excuseField = EditText(context).apply { - hint = "Your excuse (optional)" + hint = "...or type your own excuse" setTextColor(COLOR_INK) setHintTextColor(COLOR_GRAY) setBackgroundColor(COLOR_PAPER) filters = arrayOf(InputFilter.LengthFilter(INTENT_TEXT_MAX_LEN)) setSingleLine(true) setPadding(dp(16), dp(16), dp(16), dp(16)) + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { + // Manually editing away from a selected chip's exact + // text un-highlights it — the chip row reflects what's + // actually in the field, not a stale last tap. + if (suppressChipSync) return + val text = s?.toString().orEmpty() + excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == text) } + } + }) } + excuseFieldRef = excuseField root.addView(excuseField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(32) }) From a14a43d5bfe5e866ccd4e60f5999eb0c59dbf874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:02:47 +0000 Subject: [PATCH 3/9] feat: live roast reaction when typing a custom excuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the excuse field's text stops matching a chip (the user is writing their own), a red caption below the field now reacts to it — first-match keyword pattern-matching against common excuse tropes (work/boss, 'quick', boredom, blaming a friend, etc.), falling back to a generic 'writing your own?' line picked once per typing session (not re-rolled every keystroke, to avoid flicker) when nothing matches. No on-device LLM/NLP exists here, so this is pattern matching, not real understanding — same placeholder-copy caveat as EXCUSE_CHIPS. --- .../core/watcher/IntentOverlayController.kt | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 3d685e9..53fa97b 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -57,6 +57,35 @@ class IntentOverlayController(private val context: Context) { "No reason. Just vibes.", ) + // T-104 typed-excuse roast: reacts live to what the user types in + // the free-text field, once it stops matching a chip. No on-device + // LLM/NLP exists here, so "reacting to what they typed" means + // keyword pattern-matching against common excuse tropes, not real + // understanding — first match wins, "%APP%" is swapped for the + // blocked app's label. PLACEHOLDER COPY, same as EXCUSE_CHIPS. + private val EXCUSE_KEYWORD_ROASTS = listOf( + listOf("work", "job") to "\"Work\"? On %APP%? Sure.", + listOf("boss", "email", "meeting") to "Bringing the boss into this. Bold.", + listOf("quick", "fast", "sec", "second") to "\"Quick,\" they said. History disagrees.", + listOf("bored", "boredom") to "Boredom: the most honest excuse here.", + listOf("friend", "someone", "he ", "she ", "they ") to "Blaming someone else already. Confident.", + listOf("important", "urgent") to "\"Important\"? On %APP%? Suuure.", + listOf("research", "study", "studying") to "Ah yes. The research. Very academic.", + listOf("break", "rest", "tired") to "A break. From what, exactly?", + ) + + // Used when the typed text doesn't match any keyword above — still + // reacting to "they're writing a custom one," just without a + // specific hook to react to. Picked once per typing session (see + // the TextWatcher below), not re-rolled on every keystroke. + private val EXCUSE_GENERIC_ROASTS = listOf( + "Writing your own? This should be good.", + "Freestyling it. Respect the ambition.", + "The plot thickens.", + "We're listening. Skeptically.", + "Going off-script. Noted.", + ) + private const val COLOR_INK = 0xFF0D0D0D.toInt() private const val COLOR_PAPER = 0xFFFFFFFF.toInt() private const val COLOR_YELLOW = 0xFFFFE600.toInt() @@ -255,6 +284,24 @@ class IntentOverlayController(private val context: Context) { LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(12) }) + // Live reaction to a custom-typed excuse — see EXCUSE_KEYWORD_ROASTS' + // doc comment for what "reacting to what they typed" actually means + // here (pattern-matching, not real understanding). + val typedExcuseRoast = TextView(context).apply { + setTextColor(COLOR_RED) + textSize = 12f + setTypeface(typeface, android.graphics.Typeface.BOLD) + visibility = View.GONE + } + var wasCustomTyping = false + var lastGenericRoast: String? = null + fun keywordRoastFor(text: String): String? { + val lower = text.lowercase() + val (_, template) = EXCUSE_KEYWORD_ROASTS.firstOrNull { (keywords, _) -> keywords.any { lower.contains(it) } } + ?: return null + return template.replace("%APP%", appLabel(pkg)) + } + val excuseField = EditText(context).apply { hint = "...or type your own excuse" setTextColor(COLOR_INK) @@ -273,13 +320,39 @@ class IntentOverlayController(private val context: Context) { if (suppressChipSync) return val text = s?.toString().orEmpty() excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == text) } + + if (text.isEmpty() || EXCUSE_CHIPS.contains(text)) { + // Empty, or an exact chip pick (even if reached by + // typing it out by hand) — nothing custom to react + // to yet. + wasCustomTyping = false + typedExcuseRoast.visibility = View.GONE + return + } + val keywordRoast = keywordRoastFor(text) + val roast = keywordRoast ?: run { + // No specific hook this keystroke — reuse the same + // generic line for the rest of this typing session + // instead of re-rolling on every character, or it'd + // flicker line-to-line while they type. + if (!wasCustomTyping || lastGenericRoast == null) { + lastGenericRoast = EXCUSE_GENERIC_ROASTS.random() + } + lastGenericRoast!! + } + wasCustomTyping = true + typedExcuseRoast.text = "✍️ $roast" + typedExcuseRoast.visibility = View.VISIBLE } }) } excuseFieldRef = excuseField root.addView(excuseField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { bottomMargin = dp(32) }) + ).apply { bottomMargin = dp(8) }) + root.addView(typedExcuseRoast, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = dp(24) }) // Spacer pushes the CTA down, matching the Figma layout. root.addView(View(context), LinearLayout.LayoutParams(0, 0, 1f)) From b9cf9d2b3271b43bb77e031a544b7ce7ed9030a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:08:15 +0000 Subject: [PATCH 4/9] content: replace placeholder excuse/roast copy with final pack Swaps EXCUSE_CHIPS, EXCUSE_KEYWORD_ROASTS, and EXCUSE_GENERIC_ROASTS placeholder copy for the finished set. --- .../core/watcher/IntentOverlayController.kt | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 53fa97b..2b1ed10 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -45,16 +45,18 @@ class IntentOverlayController(private val context: Context) { private const val DEFAULT_MINUTES = 10 // T-104 excuse chips: pre-written self-roasting excuses (TONE_GUIDE.md - // rules apply — action, not person). PLACEHOLDER COPY — swap for the - // real pack once written; see the copywriting prompt in the PR - // description for how these were briefed. + // rules apply — action, not person). private val EXCUSE_CHIPS = listOf( - "Just checking one thing.", - "Five minutes, tops.", - "Research purposes only.", - "Emotional support scrolling.", - "I have a plan. Trust me.", - "No reason. Just vibes.", + "Need inspiration for a project.", + "Just checking the weather, basically.", + "My hands did this automatically.", + "Waiting for a massive file to download.", + "It's called a micro-break, okay?", + "A rewards program for opening my laptop.", + "Slightly overwhelmed by my to-do list.", + "Checking if the internet is still on.", + "Just making sure I'm not missing out.", + "Purely a tactical retreat.", ) // T-104 typed-excuse roast: reacts live to what the user types in @@ -62,16 +64,16 @@ class IntentOverlayController(private val context: Context) { // LLM/NLP exists here, so "reacting to what they typed" means // keyword pattern-matching against common excuse tropes, not real // understanding — first match wins, "%APP%" is swapped for the - // blocked app's label. PLACEHOLDER COPY, same as EXCUSE_CHIPS. + // blocked app's label. private val EXCUSE_KEYWORD_ROASTS = listOf( - listOf("work", "job") to "\"Work\"? On %APP%? Sure.", - listOf("boss", "email", "meeting") to "Bringing the boss into this. Bold.", - listOf("quick", "fast", "sec", "second") to "\"Quick,\" they said. History disagrees.", - listOf("bored", "boredom") to "Boredom: the most honest excuse here.", - listOf("friend", "someone", "he ", "she ", "they ") to "Blaming someone else already. Confident.", - listOf("important", "urgent") to "\"Important\"? On %APP%? Suuure.", - listOf("research", "study", "studying") to "Ah yes. The research. Very academic.", - listOf("break", "rest", "tired") to "A break. From what, exactly?", + listOf("automatic", "muscle", "habit", "accident") to "The hands have a mind of their own, apparently.", + listOf("download", "load", "render", "export") to "Ah, the classic 'waiting for technology' defense.", + listOf("music", "song", "playlist", "audio") to "An essential audio track for maximum focus, surely.", + listOf("text", "message", "ping", "notification") to "The Pavlovian response to a vibrating pocket.", + listOf("link", "source", "article", "read") to "A highly intellectual deep-dive, no doubt.", + listOf("video", "watch", "stream", "clip") to "Just one video. Which inevitably leads to twelve more.", + listOf("group", "chat", "community", "server") to "The digital village requires your immediate presence.", + listOf("break", "pause", "breathe", "lunch") to "Resting hard from the exhaustion of existing.", ) // Used when the typed text doesn't match any keyword above — still @@ -79,11 +81,14 @@ class IntentOverlayController(private val context: Context) { // specific hook to react to. Picked once per typing session (see // the TextWatcher below), not re-rolled on every keystroke. private val EXCUSE_GENERIC_ROASTS = listOf( - "Writing your own? This should be good.", - "Freestyling it. Respect the ambition.", - "The plot thickens.", - "We're listening. Skeptically.", - "Going off-script. Noted.", + "An interesting premise. Let's see how it unfolds.", + "Compiling this specific excuse into the database.", + "A bespoke justification. Freshly pressed.", + "The defense rests its case. The timer begins.", + "A unique plot twist in your productivity arc.", + "Drafting an original screenplay in the reason field.", + "The system is processing this custom explanation.", + "Crafting your own narrative. We respect the art.", ) private const val COLOR_INK = 0xFF0D0D0D.toInt() From 058c3e74f221861de7f3c711f0ece390c679ce6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:13:51 +0000 Subject: [PATCH 5/9] fix: wrap excuse chips onto new lines instead of horizontal scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 varying-width chips scrolled off-screen sideways with no visual hint there was more to see. Added a small self-contained FlowLayout (left-to-right, wraps at the available width) and swapped it in for the HorizontalScrollView + LinearLayout row — no new dependency, same plain-Android-views approach the rest of this overlay already uses. --- .../core/watcher/IntentOverlayController.kt | 76 ++++++++++++++++--- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 2b1ed10..443c8d8 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -13,10 +13,11 @@ import android.util.Log import android.view.Gravity import android.view.KeyEvent import android.view.View +import android.view.View.MeasureSpec +import android.view.ViewGroup import android.view.WindowManager import android.widget.Button import android.widget.EditText -import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView @@ -261,8 +262,14 @@ class IntentOverlayController(private val context: Context) { val excuseChipViews = mutableMapOf() var suppressChipSync = false var excuseFieldRef: EditText? = null // assigned once excuseField is built below; chip taps only fire after that - val excuseChipRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - for ((i, excuse) in EXCUSE_CHIPS.withIndex()) { + // FlowLayout, not a HorizontalScrollView: 10 chips of varying width + // scrolled off-screen sideways with no visual hint there was more + // — wrapping onto new lines keeps every option visible up front. + val excuseChipRow = FlowLayout(context).apply { + horizontalSpacing = dp(8) + verticalSpacing = dp(8) + } + for (excuse in EXCUSE_CHIPS) { val chip = TextView(context).apply { text = excuse setTextColor(COLOR_INK) @@ -278,14 +285,9 @@ class IntentOverlayController(private val context: Context) { } } excuseChipViews[excuse] = chip - val params = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT) - if (i > 0) params.marginStart = dp(8) - excuseChipRow.addView(chip, params) + excuseChipRow.addView(chip) } - root.addView(HorizontalScrollView(context).apply { - isHorizontalScrollBarEnabled = false - addView(excuseChipRow) - }, LinearLayout.LayoutParams( + root.addView(excuseChipRow, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(12) }) @@ -478,3 +480,57 @@ class IntentOverlayController(private val context: Context) { private fun dp(value: Int): Int = (value * context.resources.displayMetrics.density).toInt() } + +/** + * Minimal left-to-right wrapping layout — lays children out in a row and + * starts a new line whenever the next child would overflow the available + * width. Used for the excuse chip row: pulling in a full flexbox library + * for one wrapping row of chips would be a lot of dependency for a little + * bit of behavior, and every other overlay in this file is already plain + * Android views with no library dependency by the same reasoning. + */ +private class FlowLayout(context: Context) : ViewGroup(context) { + var horizontalSpacing = 0 + var verticalSpacing = 0 + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + var x = 0 + var y = 0 + var lineHeight = 0 + for (i in 0 until childCount) { + val child = getChildAt(i) + measureChild(child, widthMeasureSpec, heightMeasureSpec) + val childWidth = child.measuredWidth + val childHeight = child.measuredHeight + if (x > 0 && x + childWidth > maxWidth) { + x = 0 + y += lineHeight + verticalSpacing + lineHeight = 0 + } + x += childWidth + horizontalSpacing + lineHeight = maxOf(lineHeight, childHeight) + } + setMeasuredDimension(maxWidth, y + lineHeight) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val maxWidth = r - l + var x = 0 + var y = 0 + var lineHeight = 0 + for (i in 0 until childCount) { + val child = getChildAt(i) + val childWidth = child.measuredWidth + val childHeight = child.measuredHeight + if (x > 0 && x + childWidth > maxWidth) { + x = 0 + y += lineHeight + verticalSpacing + lineHeight = 0 + } + child.layout(x, y, x + childWidth, y + childHeight) + x += childWidth + horizontalSpacing + lineHeight = maxOf(lineHeight, childHeight) + } + } +} From c62ab6d9d6fdb14015588c98a14ba5c89b95dfbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:17:48 +0000 Subject: [PATCH 6/9] feat: pre-select a default excuse chip instead of an empty field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The excuse field now opens pre-filled with 'My hands did this automatically.' (DEFAULT_EXCUSE), with that chip highlighted. There's still no 'uncheck' affordance on a chip — same as before — so the only ways to change it are picking a different chip or overwriting the text by hand; the field just no longer starts blank. --- .../bonked/core/watcher/IntentOverlayController.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index 443c8d8..e9d112d 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -60,6 +60,13 @@ class IntentOverlayController(private val context: Context) { "Purely a tactical retreat.", ) + // T-104: the field starts pre-filled with this one (chip + // highlighted, text set) rather than empty — there's no + // "uncheck" affordance on a chip, only switching to a different + // chip or overwriting the text by hand. Must be an exact string + // from EXCUSE_CHIPS above, or the default won't highlight. + private const val DEFAULT_EXCUSE = "My hands did this automatically." + // T-104 typed-excuse roast: reacts live to what the user types in // the free-text field, once it stops matching a chip. No on-device // LLM/NLP exists here, so "reacting to what they typed" means @@ -354,6 +361,12 @@ class IntentOverlayController(private val context: Context) { }) } excuseFieldRef = excuseField + // Pre-select DEFAULT_EXCUSE now that the watcher is attached, so + // this fires the same afterTextChanged path a chip tap would — + // the matching chip highlights itself via the existing sync logic + // above, no separate highlighting code needed here. + excuseField.setText(DEFAULT_EXCUSE) + excuseField.setSelection(DEFAULT_EXCUSE.length) root.addView(excuseField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(8) }) From 29d345b7003dba173273ead1caaf62b9f49f6d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:20:24 +0000 Subject: [PATCH 7/9] feat: never let the excuse field settle on blank The field can still be backspaced to empty mid-edit, but that now shows a roast caption reacting to the attempt (EXCUSE_EMPTY_ROASTS) instead of quietly doing nothing. Two fallbacks then guarantee it never actually stays blank: losing focus while empty snaps the text back to DEFAULT_EXCUSE, and START's click handler falls back to the same default if it's ever tapped while the field is blank (e.g. via the IME action button, without a focus-loss event firing first). --- .../core/watcher/IntentOverlayController.kt | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index e9d112d..ac06128 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -99,6 +99,19 @@ class IntentOverlayController(private val context: Context) { "Crafting your own narrative. We respect the art.", ) + // T-104: the field is never allowed to end up blank (see + // DEFAULT_EXCUSE and the focus-loss/START fallbacks below) — but + // the user can still backspace their way to empty mid-edit, and + // when they do, this reacts to THAT instead of silently letting + // it happen. Picked at random each time the field goes empty. + private val EXCUSE_EMPTY_ROASTS = listOf( + "Nothing? Not even a bad excuse?", + "Silence isn't an excuse. Try again.", + "Even a weak excuse beats no excuse.", + "Deleting the evidence won't work here.", + "The field is judging this blank stare.", + ) + private const val COLOR_INK = 0xFF0D0D0D.toInt() private const val COLOR_PAPER = 0xFFFFFFFF.toInt() private const val COLOR_YELLOW = 0xFFFFE600.toInt() @@ -335,10 +348,20 @@ class IntentOverlayController(private val context: Context) { val text = s?.toString().orEmpty() excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == text) } - if (text.isEmpty() || EXCUSE_CHIPS.contains(text)) { - // Empty, or an exact chip pick (even if reached by - // typing it out by hand) — nothing custom to react - // to yet. + if (text.isEmpty()) { + // The field isn't allowed to stay blank (see + // DEFAULT_EXCUSE / the focus-loss and START + // fallbacks below), but it can be *reached* + // mid-edit — react to that attempt instead of + // quietly doing nothing. + wasCustomTyping = false + typedExcuseRoast.text = "✍️ ${EXCUSE_EMPTY_ROASTS.random()}" + typedExcuseRoast.visibility = View.VISIBLE + return + } + if (EXCUSE_CHIPS.contains(text)) { + // An exact chip pick (even if reached by typing it + // out by hand) — nothing custom to react to yet. wasCustomTyping = false typedExcuseRoast.visibility = View.GONE return @@ -359,6 +382,19 @@ class IntentOverlayController(private val context: Context) { typedExcuseRoast.visibility = View.VISIBLE } }) + // Belt-and-suspenders against the field settling on blank: + // the TextWatcher above already roasts a mid-edit empty + // state, but on losing focus (keyboard dismissed, tapped + // elsewhere) with nothing typed, snap back to DEFAULT_EXCUSE + // rather than leaving it blank. START's own click handler + // below has the same fallback for the case where START is + // tapped without a focus-loss ever firing first. + setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus && text.isNullOrBlank()) { + setText(DEFAULT_EXCUSE) + setSelection(DEFAULT_EXCUSE.length) + } + } } excuseFieldRef = excuseField // Pre-select DEFAULT_EXCUSE now that the watcher is attached, so @@ -383,8 +419,13 @@ class IntentOverlayController(private val context: Context) { setBackgroundColor(COLOR_INK) isAllCaps = true setOnClickListener { - val text = excuseField.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } - Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes hasText=${text != null}") + // Same DEFAULT_EXCUSE fallback as the focus-loss handler + // above, for the path where START is tapped directly + // without the field ever losing focus first (e.g. the IME + // action button) — the excuse text is no longer optional, + // so this must never pass null/blank to onGrant. + val text = excuseField.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: DEFAULT_EXCUSE + Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes text=$text") onGrant(selectedMinutes, text) dismissViewOnMainThread() // already on the main thread — this is a click listener } From 3b6c70292150b584e4e39cf12fed39d7185e77a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:28:20 +0000 Subject: [PATCH 8/9] fix: decouple excuse chips from the free-text field; gate empty submits behind a confirm tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Picking a chip no longer fills the free-text field with its text — it just selects/highlights the chip and clears the field back to its hint, so chip and free text read as two separate, mutually exclusive excuse sources instead of one prefilling the other. Typing anything in the field deselects whatever chip was active (including the DEFAULT_EXCUSE chip, which starts pre-selected on open). 2. Reworked the never-blank enforcement from last commit: instead of silently snapping back to DEFAULT_EXCUSE on focus loss / submit, an empty free-text field is now allowed, but tapping START with it active and empty shows a roast and refuses to submit on the first tap (emptyConfirmPending) — a second START tap with that roast still showing proceeds with no excuse. A selected chip never hits this path, since it's already a real excuse. --- .../core/watcher/IntentOverlayController.kt | 128 ++++++++++-------- 1 file changed, 69 insertions(+), 59 deletions(-) diff --git a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt index ac06128..a83300c 100644 --- a/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt +++ b/app/android/core/src/main/kotlin/com/arkarizdev/bonked/core/watcher/IntentOverlayController.kt @@ -99,11 +99,12 @@ class IntentOverlayController(private val context: Context) { "Crafting your own narrative. We respect the art.", ) - // T-104: the field is never allowed to end up blank (see - // DEFAULT_EXCUSE and the focus-loss/START fallbacks below) — but - // the user can still backspace their way to empty mid-edit, and - // when they do, this reacts to THAT instead of silently letting - // it happen. Picked at random each time the field goes empty. + // T-104: reused in two places — live, while the free-text field + // is empty mid-edit (a caption, doesn't block anything), and as + // the confirmation prompt when START is tapped with free text + // active and empty (does block the first tap — see the START + // click handler's emptyConfirmPending logic). Picked at random + // each time. private val EXCUSE_EMPTY_ROASTS = listOf( "Nothing? Not even a bad excuse?", "Silence isn't an excuse. Try again.", @@ -273,15 +274,19 @@ class IntentOverlayController(private val context: Context) { LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(8) }) - // Nudges the user into a roast either way, without a hard - // requirement: tapping a chip fills the field with a pre-written - // self-roasting excuse (still editable after), and typing a custom - // one is fair game too — either path means whatever ends up in - // this field is the user roasting themselves, on the record, - // before the session even starts. + // Chip and free text are mutually exclusive excuse sources, not + // "tap fills the field": picking a chip selects it (highlighted) + // and clears the field back to its hint, so it's obvious the chip + // is what's active; typing anything in the field deselects + // whatever chip was picked. Either way, whatever ends up active + // is the user roasting themselves, on the record, before the + // session even starts. val excuseChipViews = mutableMapOf() + var selectedChipExcuse: String? = DEFAULT_EXCUSE // pre-selected; see the chip loop below + var emptyConfirmPending = false // see START's click handler var suppressChipSync = false var excuseFieldRef: EditText? = null // assigned once excuseField is built below; chip taps only fire after that + var typedExcuseRoastRef: TextView? = null // assigned once typedExcuseRoast is built below, same reason // FlowLayout, not a HorizontalScrollView: 10 chips of varying width // scrolled off-screen sideways with no visual hint there was more // — wrapping onto new lines keeps every option visible up front. @@ -295,13 +300,15 @@ class IntentOverlayController(private val context: Context) { setTextColor(COLOR_INK) textSize = 12f setPadding(dp(12), dp(8), dp(12), dp(8)) - background = chipBackground(false) + background = chipBackground(excuse == DEFAULT_EXCUSE) setOnClickListener { + selectedChipExcuse = excuse + emptyConfirmPending = false suppressChipSync = true - excuseFieldRef?.setText(excuse) - excuseFieldRef?.setSelection(excuse.length) + excuseFieldRef?.setText("") // back to the hint, per point 1 — the chip is what's active now, not the field suppressChipSync = false excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == excuse) } + typedExcuseRoastRef?.visibility = View.GONE } } excuseChipViews[excuse] = chip @@ -320,6 +327,7 @@ class IntentOverlayController(private val context: Context) { setTypeface(typeface, android.graphics.Typeface.BOLD) visibility = View.GONE } + typedExcuseRoastRef = typedExcuseRoast var wasCustomTyping = false var lastGenericRoast: String? = null fun keywordRoastFor(text: String): String? { @@ -341,31 +349,28 @@ class IntentOverlayController(private val context: Context) { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit override fun afterTextChanged(s: Editable?) { - // Manually editing away from a selected chip's exact - // text un-highlights it — the chip row reflects what's - // actually in the field, not a stale last tap. if (suppressChipSync) return - val text = s?.toString().orEmpty() - excuseChipViews.forEach { (e, v) -> v.background = chipBackground(e == text) } + // Any real (user-driven) edit means free text is now + // the active source — deselect whatever chip was + // picked (including the default), and any pending + // "tap again to go empty" confirm no longer applies + // to this fresh attempt. + if (selectedChipExcuse != null) { + selectedChipExcuse = null + excuseChipViews.values.forEach { it.background = chipBackground(false) } + } + emptyConfirmPending = false + val text = s?.toString().orEmpty() if (text.isEmpty()) { - // The field isn't allowed to stay blank (see - // DEFAULT_EXCUSE / the focus-loss and START - // fallbacks below), but it can be *reached* - // mid-edit — react to that attempt instead of - // quietly doing nothing. + // Reacts to the attempt live, but doesn't block it + // — START's click handler is what actually gates + // submitting with no excuse (point 2). wasCustomTyping = false typedExcuseRoast.text = "✍️ ${EXCUSE_EMPTY_ROASTS.random()}" typedExcuseRoast.visibility = View.VISIBLE return } - if (EXCUSE_CHIPS.contains(text)) { - // An exact chip pick (even if reached by typing it - // out by hand) — nothing custom to react to yet. - wasCustomTyping = false - typedExcuseRoast.visibility = View.GONE - return - } val keywordRoast = keywordRoastFor(text) val roast = keywordRoast ?: run { // No specific hook this keystroke — reuse the same @@ -382,27 +387,8 @@ class IntentOverlayController(private val context: Context) { typedExcuseRoast.visibility = View.VISIBLE } }) - // Belt-and-suspenders against the field settling on blank: - // the TextWatcher above already roasts a mid-edit empty - // state, but on losing focus (keyboard dismissed, tapped - // elsewhere) with nothing typed, snap back to DEFAULT_EXCUSE - // rather than leaving it blank. START's own click handler - // below has the same fallback for the case where START is - // tapped without a focus-loss ever firing first. - setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus && text.isNullOrBlank()) { - setText(DEFAULT_EXCUSE) - setSelection(DEFAULT_EXCUSE.length) - } - } } excuseFieldRef = excuseField - // Pre-select DEFAULT_EXCUSE now that the watcher is attached, so - // this fires the same afterTextChanged path a chip tap would — - // the matching chip highlights itself via the existing sync logic - // above, no separate highlighting code needed here. - excuseField.setText(DEFAULT_EXCUSE) - excuseField.setSelection(DEFAULT_EXCUSE.length) root.addView(excuseField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ).apply { bottomMargin = dp(8) }) @@ -419,15 +405,39 @@ class IntentOverlayController(private val context: Context) { setBackgroundColor(COLOR_INK) isAllCaps = true setOnClickListener { - // Same DEFAULT_EXCUSE fallback as the focus-loss handler - // above, for the path where START is tapped directly - // without the field ever losing focus first (e.g. the IME - // action button) — the excuse text is no longer optional, - // so this must never pass null/blank to onGrant. - val text = excuseField.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: DEFAULT_EXCUSE - Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes text=$text") - onGrant(selectedMinutes, text) - dismissViewOnMainThread() // already on the main thread — this is a click listener + val chipExcuse = selectedChipExcuse + val typedExcuse = excuseField.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } + when { + // A chip is active (the default, or an explicit pick) + // — always a real excuse, no confirmation needed. + chipExcuse != null -> { + Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes text=$chipExcuse source=chip") + onGrant(selectedMinutes, chipExcuse) + dismissViewOnMainThread() + } + // Free text is active and has something in it. + typedExcuse != null -> { + Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes text=$typedExcuse source=typed") + onGrant(selectedMinutes, typedExcuse) + dismissViewOnMainThread() + } + // Free text is active but empty (point 2): first tap + // roasts and refuses to submit — the user has to mean + // it. Doesn't dismiss the overlay. + !emptyConfirmPending -> { + emptyConfirmPending = true + typedExcuseRoast.text = "⚠️ ${EXCUSE_EMPTY_ROASTS.random()} TAP START AGAIN TO GO WITH NO EXCUSE." + typedExcuseRoast.visibility = View.VISIBLE + Log.i(TAG, "START tapped pkg=$pkg with empty excuse — awaiting confirm") + } + // Second tap with the roast already showing: they mean + // it, proceed with no excuse. + else -> { + Log.i(TAG, "START tapped pkg=$pkg minutes=$selectedMinutes text=") + onGrant(selectedMinutes, null) + dismissViewOnMainThread() + } + } } }) From 7265c3485c2ac7d5aaae4ca4fd404808a0490d1c Mon Sep 17 00:00:00 2001 From: arkariz Date: Mon, 31 Aug 2026 21:33:29 +0700 Subject: [PATCH 9/9] fix: remove null safety checks for budgetMin in app row state --- app/lib/src/onboarding/app_picker_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/lib/src/onboarding/app_picker_screen.dart b/app/lib/src/onboarding/app_picker_screen.dart index aa98083..812d2f8 100644 --- a/app/lib/src/onboarding/app_picker_screen.dart +++ b/app/lib/src/onboarding/app_picker_screen.dart @@ -341,14 +341,14 @@ class _AppRowState extends State<_AppRow> { ), ), ), - if (selected && budgetMin != null) + if (selected) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _formatBudget(budgetMin!), + _formatBudget(budgetMin), style: const TextStyle(color: BonkedColors.ink, fontWeight: FontWeight.w900, fontSize: 16), ), SliderTheme( @@ -361,11 +361,11 @@ class _AppRowState extends State<_AppRow> { valueIndicatorTextStyle: const TextStyle(color: BonkedColors.yellow, fontWeight: FontWeight.w800), ), child: Slider( - value: budgetMin!.toDouble().clamp(_kBudgetMinMinutes.toDouble(), _kBudgetMaxMinutes.toDouble()), + value: budgetMin.toDouble().clamp(_kBudgetMinMinutes.toDouble(), _kBudgetMaxMinutes.toDouble()), min: _kBudgetMinMinutes.toDouble(), max: _kBudgetMaxMinutes.toDouble(), divisions: (_kBudgetMaxMinutes - _kBudgetMinMinutes) ~/ _kBudgetStepMinutes, - label: _formatBudget(budgetMin!), + label: _formatBudget(budgetMin), onChanged: (value) => onBudgetChanged(value.round()), ), ),