Feat 1.16.9: Circle quest first-run #10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Executable
+107
@@ -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 =="
|
||||
@@ -27,7 +27,6 @@ cat >"$PLIST" <<EOF
|
||||
<string>-avd</string><string>${AVD_NAME}</string>
|
||||
<string>-memory</string><string>3072</string>
|
||||
<string>-cores</string><string>4</string>
|
||||
<string>-no-audio</string>
|
||||
<string>-gpu</string><string>${GPU}</string>
|
||||
<string>-accel</string><string>on</string>
|
||||
<string>-no-snapshot-load</string>
|
||||
|
||||
+1
-1
@@ -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 &
|
||||
|
||||
Reference in New Issue
Block a user