From c9180330e2effe0c6a668d51e1c3ba6143209dc0 Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 5 Aug 2026 15:06:14 -0400 Subject: [PATCH] Wire Circle quest into first-run with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static onboarding with a 3-step quest (circle → hold → read/translate), slim the draw hint, and enable emulator audio. --- CHANGELOG.md | 12 ++ Makefile | 9 +- app/build.gradle.kts | 4 +- .../kotlin/com/example/crkl/MainActivity.kt | 144 +++++++++--------- .../accessibility/CrklAccessibilityService.kt | 36 ++++- .../example/crkl/accessibility/OverlayView.kt | 49 +++--- .../crkl/integrations/IntegrationSettings.kt | 9 ++ .../example/crkl/onboarding/CircleQuest.kt | 102 +++++++++++++ .../com/example/crkl/ui/ResultPanelView.kt | 13 +- .../integrations/IntegrationSettingsTest.kt | 10 ++ .../crkl/onboarding/CircleQuestTest.kt | 82 ++++++++++ docs/onboarding-quest.md | 23 +-- scripts/smoke-quest.sh | 107 +++++++++++++ scripts/start-emulator-launchd.sh | 1 - scripts/test-env.sh | 2 +- 15 files changed, 490 insertions(+), 113 deletions(-) create mode 100644 app/src/main/kotlin/com/example/crkl/onboarding/CircleQuest.kt create mode 100644 app/src/test/java/com/example/crkl/onboarding/CircleQuestTest.kt create mode 100755 scripts/smoke-quest.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 248c076..90d8593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.16.9-quest — 2026-08-05 + +- Wire playful Circle quest into first-run (MainActivity card + prefs) +- Wins: closed loop → long-press icon → Read aloud / Translate +- Tests: `CircleQuestTest` + `make smoke-quest` + +## 1.16.8-hint-audio — 2026-08-05 + +- Slimmer draw hint; hides while finger is down so it isn’t over the text +- Emulator starts **with audio** (Read aloud works on Mac speakers) +- Slightly smaller action chips so more fit on one row + ## 1.16.7-read-ideas — 2026-08-05 - Long-press any action icon → name toast diff --git a/Makefile b/Makefile index ce1f826..7898e6b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Crkl - local Android build / test / emulator .PHONY: help doctor setup-mac setup-sdk setup-emulator build test install run stop logs clean uninstall \ devices check-device push-model model-status emulator emulator-stop emulator-list wait-emulator demo \ - fixtures home back recents test-env integrations gog-bridge smoke + fixtures home back recents test-env integrations gog-bridge smoke smoke-quest UNAME_S := $(shell uname -s) GRADLEW := ./gradlew @@ -54,6 +54,7 @@ help: ## Show available commands @echo " make doctor - verify toolchain" @echo " make test - run JVM unit tests (no phone needed)" @echo " make smoke - on-device: FAB → circle → extract → Copy" + @echo " make smoke-quest - on-device: first circle advances quest 0→1" @echo " make test-env - ONE SHOT: fix chrome, install, open fixtures playground" @echo " make demo - build + boot emulator + install + launch" @echo " make fixtures - open email/image/video/audio test screen" @@ -129,7 +130,7 @@ emulator: ## Start CrklEmulator (open gRPC so rim ◀○□ chrome works) chmod +x scripts/start-emulator-launchd.sh; \ bash scripts/start-emulator-launchd.sh; \ else \ - nohup $(EMULATOR) -avd "$(AVD_NAME)" -memory 3072 -cores 4 -no-audio \ + nohup $(EMULATOR) -avd "$(AVD_NAME)" -memory 3072 -cores 4 \ -gpu $(EMULATOR_GPU) -accel on -no-snapshot-load -no-metrics \ -grpc 8554 \ >/tmp/crkl-emulator.log 2>&1 & echo $$! > /tmp/crkl-emulator.pid; \ @@ -158,6 +159,10 @@ smoke: ## On-device smoke (FAB → circle → extract → Copy). Needs adb devic @chmod +x scripts/smoke-circle.sh @ADB="$(ADB)" bash scripts/smoke-circle.sh +smoke-quest: ## On-device: reset quest prefs, circle once, assert step=1 + @chmod +x scripts/smoke-quest.sh + @ADB="$(ADB)" bash scripts/smoke-quest.sh + build: ## Build debug APK @echo "Building Crkl..." @$(GRADLEW) assembleDebug --console=plain diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 994fd4d..f6851de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.example.crkl" minSdk = 27 targetSdk = 34 - versionCode = 24 - versionName = "1.16.7-read-ideas" + versionCode = 26 + versionName = "1.16.9-quest" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/kotlin/com/example/crkl/MainActivity.kt b/app/src/main/kotlin/com/example/crkl/MainActivity.kt index c36de7f..417f312 100644 --- a/app/src/main/kotlin/com/example/crkl/MainActivity.kt +++ b/app/src/main/kotlin/com/example/crkl/MainActivity.kt @@ -2,8 +2,10 @@ package com.example.crkl import android.Manifest import android.accessibilityservice.AccessibilityServiceInfo +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -33,6 +35,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -56,6 +59,7 @@ import com.example.crkl.capture.MediaProjectionHolder import com.example.crkl.capture.ProjectionPermissionActivity import com.example.crkl.fixtures.TestFixturesActivity import com.example.crkl.integrations.IntegrationSettings +import com.example.crkl.onboarding.CircleQuest import com.example.crkl.ui.theme.CrklTheme import java.util.concurrent.Executor @@ -85,6 +89,7 @@ class MainActivity : ComponentActivity() { val settings = remember { IntegrationSettings(this) } var a11yOn by remember { mutableStateOf(isCrklAccessibilityEnabled()) } var onboardingDone by remember { mutableStateOf(settings.onboardingComplete) } + var questStep by remember { mutableIntStateOf(settings.questStep) } var overlayReady by remember { mutableStateOf(CrklOverlayBridge.isReady()) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -92,19 +97,39 @@ class MainActivity : ComponentActivity() { if (event == Lifecycle.Event.ON_RESUME) { a11yOn = isCrklAccessibilityEnabled() onboardingDone = settings.onboardingComplete + questStep = settings.questStep captureReady = MediaProjectionHolder.isReady() overlayReady = CrklOverlayBridge.isReady() } } lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + val questReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + onboardingDone = settings.onboardingComplete + questStep = intent?.getIntExtra( + CircleQuest.EXTRA_STEP, + settings.questStep + ) ?: settings.questStep + } + } + ContextCompat.registerReceiver( + this@MainActivity, + questReceiver, + IntentFilter(CircleQuest.ACTION_PROGRESS), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + unregisterReceiver(questReceiver) + } } MainScreen( captureReady = captureReady, integrationHint = settings.statusSummary(), accessibilityEnabled = a11yOn, - showOnboarding = !onboardingDone, + showQuest = !onboardingDone && questStep < CircleQuest.DONE, + questStep = questStep, onRefreshA11y = { a11yOn = isCrklAccessibilityEnabled() overlayReady = CrklOverlayBridge.isReady() @@ -120,8 +145,6 @@ class MainActivity : ComponentActivity() { startActivity(Intent(this, IntegrationsActivity::class.java)) }, onOpenTestFixtures = { - settings.onboardingComplete = true - onboardingDone = true startActivity(Intent(this, TestFixturesActivity::class.java)) }, onEnableCapture = { @@ -130,9 +153,10 @@ class MainActivity : ComponentActivity() { captureReady = MediaProjectionHolder.isReady() }, 1500) }, - onSkipOnboarding = { - settings.onboardingComplete = true + onSkipQuest = { + CircleQuest.skip(settings) onboardingDone = true + questStep = CircleQuest.DONE }, onAddQsTile = { requestQsTile() }, canAddQsTile = Build.VERSION.SDK_INT >= 33, @@ -208,13 +232,14 @@ fun MainScreen( captureReady: Boolean, integrationHint: String, accessibilityEnabled: Boolean, - showOnboarding: Boolean, + showQuest: Boolean, + questStep: Int, onRefreshA11y: () -> Unit, onOpenAccessibilitySettings: () -> Unit, onOpenIntegrations: () -> Unit, onOpenTestFixtures: () -> Unit, onEnableCapture: () -> Unit, - onSkipOnboarding: () -> Unit, + onSkipQuest: () -> Unit, onAddQsTile: () -> Unit = {}, canAddQsTile: Boolean = false, overlayReady: Boolean = false @@ -278,7 +303,7 @@ fun MainScreen( } } - if (showOnboarding) { + if (showQuest) { Spacer(modifier = Modifier.height(20.dp)) Card( modifier = Modifier.fillMaxWidth(), @@ -286,46 +311,52 @@ fun MainScreen( containerColor = MaterialTheme.colorScheme.primaryContainer ) ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Get started", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text( - "Three steps — then circle anything on screen.", - style = MaterialTheme.typography.bodySmall + CircleQuest.title(questStep), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium ) - OnboardStep( - number = 1, - title = "Enable Accessibility", - done = accessibilityEnabled, - actionLabel = if (accessibilityEnabled) "Enabled" else "Open settings", - onAction = { - onOpenAccessibilitySettings() - onRefreshA11y() - }, - enabled = !accessibilityEnabled + Text( + CircleQuest.dots(questStep), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary ) - OnboardStep( - number = 2, - title = "Integrations (optional)", - done = false, - actionLabel = "Open Integrations", - onAction = onOpenIntegrations, - enabled = true, - detail = "Vikunja token, translate target, circle style" - ) - OnboardStep( - number = 3, - title = "Try a circle", - done = false, - actionLabel = "Open test fixtures", - onAction = onOpenTestFixtures, - enabled = true, - detail = "Tap the C → closed loop around text → Translate / Copy / Explain" + Text( + CircleQuest.prompt(questStep), + style = MaterialTheme.typography.bodyMedium ) + if (!accessibilityEnabled && questStep == CircleQuest.STEP_CIRCLE) { + Text( + "First: enable Accessibility so the floating C can appear.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = { + onOpenAccessibilitySettings() + onRefreshA11y() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Enable Accessibility") + } + } else if (questStep == CircleQuest.STEP_CIRCLE) { + OutlinedButton( + onClick = onOpenTestFixtures, + modifier = Modifier.fillMaxWidth() + ) { + Text("Open test fixtures") + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { - TextButton(onClick = onSkipOnboarding) { + TextButton(onClick = onSkipQuest) { Text("Skip for now") } } @@ -397,7 +428,7 @@ fun MainScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Panel chips: Translate · Copy · Explain · Share · Vikunja", + text = "Panel chips: Translate · Copy · Explain · Read · Share · Todo · ?", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -405,32 +436,3 @@ fun MainScreen( Spacer(modifier = Modifier.height(24.dp)) } } - -@Composable -private fun OnboardStep( - number: Int, - title: String, - done: Boolean, - actionLabel: String, - onAction: () -> Unit, - enabled: Boolean, - detail: String? = null -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "$number. $title" + if (done) " ✓" else "", - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.bodyMedium - ) - if (detail != null) { - Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - OutlinedButton( - onClick = onAction, - enabled = enabled || done, - modifier = Modifier.fillMaxWidth() - ) { - Text(actionLabel) - } - } -} diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt index 0b850c5..09eef0c 100644 --- a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt +++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt @@ -1,6 +1,7 @@ package com.example.crkl.accessibility import android.accessibilityservice.AccessibilityService +import android.content.Intent import android.content.pm.PackageManager import android.graphics.PixelFormat import android.graphics.RectF @@ -11,6 +12,7 @@ import android.util.Log import android.view.Gravity import android.view.WindowManager import android.view.accessibility.AccessibilityEvent +import android.widget.Toast import androidx.core.content.ContextCompat import com.example.crkl.agent.ActionHints import com.example.crkl.agent.ActionExecutor @@ -21,6 +23,7 @@ import com.example.crkl.model.DeviceSpeechStt import com.example.crkl.model.DeviceTts import com.example.crkl.model.MediaPipeLocalLlm import com.example.crkl.integrations.IntegrationSettings +import com.example.crkl.onboarding.CircleQuest import com.example.crkl.ui.ResultPanelView import com.example.crkl.vision.ContentCapture import com.example.crkl.vision.ScreenOcr @@ -32,7 +35,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - /** * System overlay + selection pipeline. * @@ -233,6 +235,7 @@ class CrklAccessibilityService : AccessibilityService() { private fun handleSelection(bounds: RectF) { serviceScope.launch { overlayView?.exitOverlayMode() + noteQuestWin(CircleQuest.Win.CIRCLE) showResultPanel( AssistEngine.AssistResponse( title = "Reading…", @@ -377,11 +380,18 @@ class CrklAccessibilityService : AccessibilityService() { onListen = { startSttFromPanel() }, onShareList = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.SHARE_LIST, raw = "share")) }, onAddTodo = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.ADD_TO_TODO, raw = "add to todo")) }, - onTranslate = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.TRANSLATE, raw = "translate")) }, + onTranslate = { + runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.TRANSLATE, raw = "translate")) + noteQuestWin(CircleQuest.Win.ACTION) + }, onCopy = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.COPY, raw = "copy")) }, onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) }, - onReadAloud = { readAloudFromPanel() }, - onIdeas = { showIdeasFromPanel() } + onReadAloud = { + readAloudFromPanel() + noteQuestWin(CircleQuest.Win.ACTION) + }, + onIdeas = { showIdeasFromPanel() }, + onChipLongPress = { noteQuestWin(CircleQuest.Win.HOLD_ICON) } ) panel.showResult(response.title, response.meta, response.body) resultPanel = panel @@ -404,6 +414,24 @@ class CrklAccessibilityService : AccessibilityService() { } } + private fun noteQuestWin(win: CircleQuest.Win) { + val settings = IntegrationSettings(this) + val adv = CircleQuest.applyTo(settings, win) + if (!adv.changed) return + Log.i(tagName, "quest: step=${adv.to} win=$win") + sendBroadcast( + Intent(CircleQuest.ACTION_PROGRESS).setPackage(packageName).apply { + putExtra(CircleQuest.EXTRA_STEP, adv.to) + putExtra(CircleQuest.EXTRA_CHEER, adv.cheer) + } + ) + adv.cheer?.let { msg -> + mainHandler.post { + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + } + } + } + private fun readAloudFromPanel() { val body = resultPanel?.currentBody().orEmpty() val speakable = body diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt b/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt index 5b9734e..11e397e 100644 --- a/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt +++ b/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt @@ -342,25 +342,38 @@ class OverlayView( private fun drawOverlayMode(canvas: Canvas) { canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimPaint) + // Hint only when idle — big center pill blocks the text you’re trying to circle. val failing = System.currentTimeMillis() < failFlashUntil - val pillW = dp(280f) - val pillH = dp(38f) - val pillTop = statusBarInset() + dp(12f) - val pill = RectF( - width / 2f - pillW / 2f, - pillTop, - width / 2f + pillW / 2f, - pillTop + pillH - ) - canvas.drawRoundRect(pill, dp(19f), dp(19f), hintPillPaint) - canvas.drawRoundRect(pill, dp(19f), dp(19f), hintRingPaint) - instructionPaint.color = if (failing) Color.parseColor("#FFCFC4") else Color.WHITE - canvas.drawText( - hintMessage, - pill.centerX(), - pill.centerY() + instructionPaint.textSize / 3f, - instructionPaint - ) + val showHint = failing || (!isDrawing && touchPath.isEmpty()) + if (showHint) { + val label = if (failing) hintMessage else "Circle text · Cancel →" + val pillW = instructionPaint.measureText(label) + dp(28f) + val pillH = dp(32f) + val pillTop = statusBarInset() + dp(8f) + val pill = RectF( + width / 2f - pillW / 2f, + pillTop, + width / 2f + pillW / 2f, + pillTop + pillH + ) + // Keep clear of Cancel on the right + if (pill.right > width - dp(110f)) { + pill.offset(width - dp(110f) - pill.right, 0f) + } + if (pill.left < dp(12f)) { + pill.offset(dp(12f) - pill.left, 0f) + } + canvas.drawRoundRect(pill, dp(16f), dp(16f), hintPillPaint) + canvas.drawRoundRect(pill, dp(16f), dp(16f), hintRingPaint) + instructionPaint.color = if (failing) Color.parseColor("#FFCFC4") else Color.WHITE + instructionPaint.textSize = sp(13f) + canvas.drawText( + label, + pill.centerX(), + pill.centerY() + instructionPaint.textSize / 3f, + instructionPaint + ) + } updateExitRect() canvas.drawRoundRect(exitRect, dp(18f), dp(18f), exitBgPaint) diff --git a/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt b/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt index 83ac5d4..ee7a597 100644 --- a/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt +++ b/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt @@ -68,6 +68,14 @@ class IntegrationSettings( get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false) set(value) = prefs.edit().putBoolean(KEY_ONBOARDING_DONE, value).apply() + /** + * Circle quest progress: 0 = circle, 1 = hold icon, 2 = action, 3 = done. + * Kept in sync with [onboardingComplete] by [com.example.crkl.onboarding.CircleQuest]. + */ + var questStep: Int + get() = prefs.getInt(KEY_QUEST_STEP, 0).coerceIn(0, 3) + set(value) = prefs.edit().putInt(KEY_QUEST_STEP, value.coerceIn(0, 3)).apply() + /** Show capture source / OCR counts on the result panel (for debugging). */ var showDebugMeta: Boolean get() = prefs.getBoolean(KEY_SHOW_DEBUG_META, false) @@ -123,6 +131,7 @@ class IntegrationSettings( private const val KEY_CIRCLE_STROKE = "circle_stroke" private const val KEY_TRANSLATE_TARGET = "translate_target" private const val KEY_ONBOARDING_DONE = "onboarding_done" + private const val KEY_QUEST_STEP = "quest_step" private const val KEY_SHOW_DEBUG_META = "show_debug_meta" const val DEFAULT_VIKUNJA_URL = "https://todo.levkin.ca" diff --git a/app/src/main/kotlin/com/example/crkl/onboarding/CircleQuest.kt b/app/src/main/kotlin/com/example/crkl/onboarding/CircleQuest.kt new file mode 100644 index 0000000..bcc8077 --- /dev/null +++ b/app/src/main/kotlin/com/example/crkl/onboarding/CircleQuest.kt @@ -0,0 +1,102 @@ +package com.example.crkl.onboarding + +import com.example.crkl.integrations.IntegrationSettings + +/** + * Playful 3-step first-run quest. No XP — just progress + cheeky wins. + * See docs/onboarding-quest.md. + */ +object CircleQuest { + + const val ACTION_PROGRESS = "com.example.crkl.QUEST_PROGRESS" + const val EXTRA_STEP = "quest_step" + const val EXTRA_CHEER = "quest_cheer" + + const val STEP_CIRCLE = 0 + const val STEP_HOLD = 1 + const val STEP_ACTION = 2 + const val DONE = 3 + + enum class Win { + /** Closed loop → result panel. */ + CIRCLE, + /** Long-press an action icon. */ + HOLD_ICON, + /** Translate or Read aloud. */ + ACTION + } + + data class Advance( + val from: Int, + val to: Int, + val cheer: String? + ) { + val changed: Boolean get() = from != to + } + + fun applyWin(current: Int, win: Win): Advance { + val step = current.coerceIn(STEP_CIRCLE, DONE) + val to = when { + step >= DONE -> DONE + win == Win.CIRCLE && step == STEP_CIRCLE -> STEP_HOLD + win == Win.HOLD_ICON && step == STEP_HOLD -> STEP_ACTION + win == Win.ACTION && step == STEP_ACTION -> DONE + else -> step + } + return Advance( + from = step, + to = to, + cheer = if (to != step) cheerFor(to) else null + ) + } + + /** Mutates settings when the win matches the current step. */ + fun applyTo(settings: IntegrationSettings, win: Win): Advance { + if (settings.onboardingComplete) { + return Advance(DONE, DONE, null) + } + val adv = applyWin(settings.questStep, win) + if (adv.changed) { + settings.questStep = adv.to + if (adv.to >= DONE) { + settings.onboardingComplete = true + } + } + return adv + } + + fun skip(settings: IntegrationSettings) { + settings.questStep = DONE + settings.onboardingComplete = true + } + + fun isActive(settings: IntegrationSettings): Boolean = + !settings.onboardingComplete && settings.questStep < DONE + + fun title(step: Int): String = when (step.coerceIn(STEP_CIRCLE, DONE)) { + STEP_CIRCLE -> "Circle quest · 1/3" + STEP_HOLD -> "Circle quest · 2/3" + STEP_ACTION -> "Circle quest · 3/3" + else -> "Quest complete" + } + + fun prompt(step: Int): String = when (step.coerceIn(STEP_CIRCLE, DONE)) { + STEP_CIRCLE -> "Tap the floating C, then draw a closed loop around text." + STEP_HOLD -> "Nice loop. Press and hold any action icon to learn its name." + STEP_ACTION -> "You're circling. Try Read aloud or Translate." + else -> "You're set — circle anything, anytime." + } + + /** Filled dots for completed wins (0–3). */ + fun dots(step: Int): String { + val filled = step.coerceIn(0, 3) + return (0 until 3).joinToString(" ") { i -> if (i < filled) "●" else "○" } + } + + private fun cheerFor(newStep: Int): String = when (newStep) { + STEP_HOLD -> "Nice loop." + STEP_ACTION -> "You're circling." + DONE -> "Quest complete — go circle the world." + else -> "Nice." + } +} diff --git a/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt b/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt index 72ada3d..7a9b968 100644 --- a/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt +++ b/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt @@ -30,7 +30,8 @@ class ResultPanelView( private val onCopy: (() -> Unit)? = null, private val onExplain: (() -> Unit)? = null, private val onReadAloud: (() -> Unit)? = null, - private val onIdeas: (() -> Unit)? = null + private val onIdeas: (() -> Unit)? = null, + private val onChipLongPress: (() -> Unit)? = null ) : LinearLayout(context) { private val titleView: TextView @@ -142,18 +143,19 @@ class ResultPanelView( bg: Int, onClick: () -> Unit ): ImageButton { - val size = dp(44) + // 40dp + 6 margin → ~7 chips fit on a typical phone without scrolling. + val size = dp(40) return ImageButton(context).apply { setImageResource(iconRes) imageTintList = android.content.res.ColorStateList.valueOf(CrklUi.ChipFg) scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(dp(10), dp(10), dp(10), dp(10)) + setPadding(dp(8), dp(8), dp(8), dp(8)) background = GradientDrawable().apply { setColor(bg) - cornerRadius = dp(22).toFloat() + cornerRadius = dp(20).toFloat() } layoutParams = LayoutParams(size, size).apply { - marginEnd = dp(8) + marginEnd = dp(6) } isClickable = true isFocusable = true @@ -162,6 +164,7 @@ class ResultPanelView( setOnLongClickListener { android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT) .show() + onChipLongPress?.invoke() true } setOnClickListener { diff --git a/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt b/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt index f6471d1..01b4e8f 100644 --- a/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt +++ b/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt @@ -32,4 +32,14 @@ class IntegrationSettingsTest { s.onboardingComplete = true assertTrue(s.onboardingComplete) } + + @Test + fun questStep_defaultsZeroAndClamps() { + val s = IntegrationSettings(MemorySharedPreferences()) + assertEquals(0, s.questStep) + s.questStep = 2 + assertEquals(2, s.questStep) + s.questStep = 99 + assertEquals(3, s.questStep) + } } diff --git a/app/src/test/java/com/example/crkl/onboarding/CircleQuestTest.kt b/app/src/test/java/com/example/crkl/onboarding/CircleQuestTest.kt new file mode 100644 index 0000000..76d0a9a --- /dev/null +++ b/app/src/test/java/com/example/crkl/onboarding/CircleQuestTest.kt @@ -0,0 +1,82 @@ +package com.example.crkl.onboarding + +import com.example.crkl.integrations.IntegrationSettings +import com.example.crkl.testutil.MemorySharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CircleQuestTest { + + @Test + fun applyWin_advancesInOrderOnly() { + assertEquals(CircleQuest.STEP_HOLD, CircleQuest.applyWin(0, CircleQuest.Win.CIRCLE).to) + assertEquals(0, CircleQuest.applyWin(0, CircleQuest.Win.HOLD_ICON).to) + assertEquals(0, CircleQuest.applyWin(0, CircleQuest.Win.ACTION).to) + + assertEquals(CircleQuest.STEP_ACTION, CircleQuest.applyWin(1, CircleQuest.Win.HOLD_ICON).to) + assertEquals(1, CircleQuest.applyWin(1, CircleQuest.Win.CIRCLE).to) + + assertEquals(CircleQuest.DONE, CircleQuest.applyWin(2, CircleQuest.Win.ACTION).to) + assertEquals(2, CircleQuest.applyWin(2, CircleQuest.Win.HOLD_ICON).to) + } + + @Test + fun applyWin_cheerOnAdvance() { + val a = CircleQuest.applyWin(0, CircleQuest.Win.CIRCLE) + assertTrue(a.changed) + assertEquals("Nice loop.", a.cheer) + + val noop = CircleQuest.applyWin(0, CircleQuest.Win.ACTION) + assertFalse(noop.changed) + assertNull(noop.cheer) + } + + @Test + fun applyTo_persistsAndCompletes() { + val s = IntegrationSettings(MemorySharedPreferences()) + assertTrue(CircleQuest.isActive(s)) + + CircleQuest.applyTo(s, CircleQuest.Win.CIRCLE) + assertEquals(1, s.questStep) + assertFalse(s.onboardingComplete) + + CircleQuest.applyTo(s, CircleQuest.Win.HOLD_ICON) + assertEquals(2, s.questStep) + + val done = CircleQuest.applyTo(s, CircleQuest.Win.ACTION) + assertEquals(CircleQuest.DONE, done.to) + assertEquals(3, s.questStep) + assertTrue(s.onboardingComplete) + assertFalse(CircleQuest.isActive(s)) + } + + @Test + fun applyTo_ignoresWhenComplete() { + val s = IntegrationSettings(MemorySharedPreferences()) + CircleQuest.skip(s) + val adv = CircleQuest.applyTo(s, CircleQuest.Win.CIRCLE) + assertFalse(adv.changed) + assertEquals(CircleQuest.DONE, s.questStep) + } + + @Test + fun skip_marksDone() { + val s = IntegrationSettings(MemorySharedPreferences()) + CircleQuest.skip(s) + assertTrue(s.onboardingComplete) + assertEquals(CircleQuest.DONE, s.questStep) + } + + @Test + fun copy_hasProgressAndPrompts() { + assertTrue(CircleQuest.title(0).contains("1/3")) + assertTrue(CircleQuest.prompt(1).contains("hold", ignoreCase = true)) + assertTrue(CircleQuest.prompt(2).contains("Read aloud") || CircleQuest.prompt(2).contains("Translate")) + assertEquals("○ ○ ○", CircleQuest.dots(0)) + assertEquals("● ○ ○", CircleQuest.dots(1)) + assertEquals("● ● ●", CircleQuest.dots(3)) + } +} diff --git a/docs/onboarding-quest.md b/docs/onboarding-quest.md index 09fe295..c10b660 100644 --- a/docs/onboarding-quest.md +++ b/docs/onboarding-quest.md @@ -8,20 +8,25 @@ Goal: first-run feels like a tiny game, not a settings dump. |------|--------|-----| | 1 | “Circle anything with the floating **C**” | First closed loop | | 2 | “Hold an icon — learn its name” | Long-press toast | -| 3 | “Try **Read aloud** or **Translate**” | One action | -| 4 | Optional: “Open fixtures → email → Todo” | Vikunja configured | +| 3 | “Try **Read aloud** or **Translate**” | One of those actions | + +Optional later: fixtures → email → Todo (Vikunja) — not part of the gated quest. ## Tone - Short, cheeky copy (“Nice loop.” / “You’re circling.”) -- Progress dots (1/3 · 2/3 · 3/3), not a points economy -- Skip always available; never block Overlay enable +- Progress dots (○ ○ ○ → ● ● ●), not a points economy +- **Skip** always available; never block Overlay enable + +## Implementation + +- State: `IntegrationSettings.questStep` + `onboardingComplete` +- Logic: `onboarding/CircleQuest.kt` +- Wins from `CrklAccessibilityService` (circle / long-press / translate·read) +- UI: MainActivity quest card; live updates via `CircleQuest.ACTION_PROGRESS` +- Tests: `CircleQuestTest` (JVM) · `make smoke-quest` (device step 0→1) ## Avoid -- Streaks / leaderboards / XP (wrong product) +- Streaks / leaderboards / XP - Multi-screen wizard before Accessibility is on - -## Ship next - -Wire quest state into `IntegrationSettings` + MainActivity card that advances on first successful circle (service → broadcast / prefs). diff --git a/scripts/smoke-quest.sh b/scripts/smoke-quest.sh new file mode 100755 index 0000000..1da422a --- /dev/null +++ b/scripts/smoke-quest.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# On-device: first closed loop advances Circle quest 0 → 1. +# Hold/action steps are covered by JVM CircleQuestTest. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ADB="${ADB:-adb}" +COMP='com.example.crkl/com.example.crkl.accessibility.CrklAccessibilityService' +PKG=com.example.crkl + +die() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "OK: $*"; } + +"$ADB" devices | awk '/device( |$)/ && $1 !~ /List/{found=1} END{exit !found}' \ + || die "no adb device" + +echo "== smoke quest on $($ADB get-serialno) ==" + +# Fresh quest prefs (debug builds only — run-as). +"$ADB" shell settings put secure accessibility_enabled 1 +"$ADB" shell settings put secure enabled_accessibility_services "$COMP" +"$ADB" shell am force-stop "$PKG" >/dev/null 2>&1 || true +sleep 0.5 +"$ADB" shell run-as "$PKG" rm -f shared_prefs/crkl_integrations.xml \ + || die "run-as failed — need debug APK" +"$ADB" shell settings put secure enabled_accessibility_services "$COMP" +"$ADB" shell settings put secure accessibility_enabled 1 +sleep 1.5 + +"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null +sleep 1.5 + +enter_circle() { + "$ADB" logcat -c + "$ADB" shell input tap 964 2200 + sleep 0.4 + if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then + return 0 + fi + for y in $(seq 2140 20 2280); do + for x in $(seq 900 20 1030); do + "$ADB" shell input tap "$x" "$y" + sleep 0.05 + if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then + return 0 + fi + done + done + return 1 +} + +draw_loop() { + local cx="$1" cy="$2" r="$3" + python3 - "$cx" "$cy" "$r" <<'PY' +import math, subprocess, sys, time +cx, cy, r = map(int, sys.argv[1:4]) +adb = lambda *a: subprocess.check_call(["adb", *a]) +n = 28 +pts = [ + ( + int(cx + r * math.cos(2 * math.pi * i / n - math.pi / 2)), + int(cy + r * math.sin(2 * math.pi * i / n - math.pi / 2)), + ) + for i in range(n + 1) +] +adb("shell", "input", "motionevent", "DOWN", str(pts[0][0]), str(pts[0][1])) +for x, y in pts[1:]: + adb("shell", "input", "motionevent", "MOVE", str(x), str(y)) +adb("shell", "input", "motionevent", "UP", str(pts[-1][0]), str(pts[-1][1])) +for _ in range(25): + time.sleep(0.3) + out = subprocess.check_output(["adb", "logcat", "-d"], text=True) + if "extract:" in out or "Selection ready" in out or "quest: step=" in out: + break +PY +} + +"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null +"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke-quest.xml >/dev/null +eval "$(python3 <<'PY' +import re, sys +xml = open("/tmp/crkl-smoke-quest.xml").read() +m = re.search(r'text="How to circle"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if not m: + sys.exit("How to circle not on screen") +x1, y1, x2, y2 = map(int, m.groups()) +cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 +r = max(60, min(100, (x2 - x1) // 2 + 30, (y2 - y1) // 2 + 40)) +print(f"HEAD_CX={cx}; HEAD_CY={cy}; HEAD_R={r}") +PY +)" + +enter_circle || die "FAB not clickable — is Circle Overlay ON?" +draw_loop "$HEAD_CX" "$HEAD_CY" "$HEAD_R" +sleep 1 + +logs="$("$ADB" logcat -d)" +echo "$logs" | grep -q 'quest: step=1' || die "quest did not advance to step 1 (check logcat)" +pass "log: quest step=1 after first circle" + +prefs="$("$ADB" shell run-as "$PKG" cat shared_prefs/crkl_integrations.xml 2>/dev/null || true)" +echo "$prefs" | grep -q 'name="quest_step" value="1"' \ + || die "prefs quest_step != 1 (got: $(echo "$prefs" | tr '\n' ' ' | head -c 200))" +pass "prefs: quest_step=1" + +# Skip path via clearing + writing would need app code; JVM covers skip(). +echo "== smoke quest PASSED ==" diff --git a/scripts/start-emulator-launchd.sh b/scripts/start-emulator-launchd.sh index e3215b3..b21eaf5 100755 --- a/scripts/start-emulator-launchd.sh +++ b/scripts/start-emulator-launchd.sh @@ -27,7 +27,6 @@ cat >"$PLIST" <-avd${AVD_NAME} -memory3072 -cores4 - -no-audio -gpu${GPU} -accelon -no-snapshot-load diff --git a/scripts/test-env.sh b/scripts/test-env.sh index 933162f..6a70654 100755 --- a/scripts/test-env.sh +++ b/scripts/test-env.sh @@ -45,7 +45,7 @@ ensure_emulator() { chmod +x "$ROOT/scripts/start-emulator-launchd.sh" bash "$ROOT/scripts/start-emulator-launchd.sh" else - nohup "$EMU" -avd "$AVD_NAME" -memory 3072 -cores 4 -no-audio \ + nohup "$EMU" -avd "$AVD_NAME" -memory 3072 -cores 4 \ -gpu "$GPU" -accel on -no-snapshot-load -no-metrics \ -grpc 8554 \ >"$LOG" 2>&1 &