Wire Circle quest into first-run with tests
Replace static onboarding with a 3-step quest (circle → hold → read/translate), slim the draw hint, and enable emulator audio.
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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
|
## 1.16.7-read-ideas — 2026-08-05
|
||||||
|
|
||||||
- Long-press any action icon → name toast
|
- Long-press any action icon → name toast
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Crkl - local Android build / test / emulator
|
# Crkl - local Android build / test / emulator
|
||||||
.PHONY: help doctor setup-mac setup-sdk setup-emulator build test install run stop logs clean uninstall \
|
.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 \
|
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)
|
UNAME_S := $(shell uname -s)
|
||||||
GRADLEW := ./gradlew
|
GRADLEW := ./gradlew
|
||||||
@@ -54,6 +54,7 @@ help: ## Show available commands
|
|||||||
@echo " make doctor - verify toolchain"
|
@echo " make doctor - verify toolchain"
|
||||||
@echo " make test - run JVM unit tests (no phone needed)"
|
@echo " make test - run JVM unit tests (no phone needed)"
|
||||||
@echo " make smoke - on-device: FAB → circle → extract → Copy"
|
@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 test-env - ONE SHOT: fix chrome, install, open fixtures playground"
|
||||||
@echo " make demo - build + boot emulator + install + launch"
|
@echo " make demo - build + boot emulator + install + launch"
|
||||||
@echo " make fixtures - open email/image/video/audio test screen"
|
@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; \
|
chmod +x scripts/start-emulator-launchd.sh; \
|
||||||
bash scripts/start-emulator-launchd.sh; \
|
bash scripts/start-emulator-launchd.sh; \
|
||||||
else \
|
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 \
|
-gpu $(EMULATOR_GPU) -accel on -no-snapshot-load -no-metrics \
|
||||||
-grpc 8554 \
|
-grpc 8554 \
|
||||||
>/tmp/crkl-emulator.log 2>&1 & echo $$! > /tmp/crkl-emulator.pid; \
|
>/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
|
@chmod +x scripts/smoke-circle.sh
|
||||||
@ADB="$(ADB)" bash 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
|
build: ## Build debug APK
|
||||||
@echo "Building Crkl..."
|
@echo "Building Crkl..."
|
||||||
@$(GRADLEW) assembleDebug --console=plain
|
@$(GRADLEW) assembleDebug --console=plain
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.example.crkl"
|
applicationId = "com.example.crkl"
|
||||||
minSdk = 27
|
minSdk = 27
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 24
|
versionCode = 26
|
||||||
versionName = "1.16.7-read-ideas"
|
versionName = "1.16.9-quest"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.example.crkl
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.accessibilityservice.AccessibilityServiceInfo
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.capture.ProjectionPermissionActivity
|
||||||
import com.example.crkl.fixtures.TestFixturesActivity
|
import com.example.crkl.fixtures.TestFixturesActivity
|
||||||
import com.example.crkl.integrations.IntegrationSettings
|
import com.example.crkl.integrations.IntegrationSettings
|
||||||
|
import com.example.crkl.onboarding.CircleQuest
|
||||||
import com.example.crkl.ui.theme.CrklTheme
|
import com.example.crkl.ui.theme.CrklTheme
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val settings = remember { IntegrationSettings(this) }
|
val settings = remember { IntegrationSettings(this) }
|
||||||
var a11yOn by remember { mutableStateOf(isCrklAccessibilityEnabled()) }
|
var a11yOn by remember { mutableStateOf(isCrklAccessibilityEnabled()) }
|
||||||
var onboardingDone by remember { mutableStateOf(settings.onboardingComplete) }
|
var onboardingDone by remember { mutableStateOf(settings.onboardingComplete) }
|
||||||
|
var questStep by remember { mutableIntStateOf(settings.questStep) }
|
||||||
var overlayReady by remember { mutableStateOf(CrklOverlayBridge.isReady()) }
|
var overlayReady by remember { mutableStateOf(CrklOverlayBridge.isReady()) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
@@ -92,19 +97,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
a11yOn = isCrklAccessibilityEnabled()
|
a11yOn = isCrklAccessibilityEnabled()
|
||||||
onboardingDone = settings.onboardingComplete
|
onboardingDone = settings.onboardingComplete
|
||||||
|
questStep = settings.questStep
|
||||||
captureReady = MediaProjectionHolder.isReady()
|
captureReady = MediaProjectionHolder.isReady()
|
||||||
overlayReady = CrklOverlayBridge.isReady()
|
overlayReady = CrklOverlayBridge.isReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
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(
|
MainScreen(
|
||||||
captureReady = captureReady,
|
captureReady = captureReady,
|
||||||
integrationHint = settings.statusSummary(),
|
integrationHint = settings.statusSummary(),
|
||||||
accessibilityEnabled = a11yOn,
|
accessibilityEnabled = a11yOn,
|
||||||
showOnboarding = !onboardingDone,
|
showQuest = !onboardingDone && questStep < CircleQuest.DONE,
|
||||||
|
questStep = questStep,
|
||||||
onRefreshA11y = {
|
onRefreshA11y = {
|
||||||
a11yOn = isCrklAccessibilityEnabled()
|
a11yOn = isCrklAccessibilityEnabled()
|
||||||
overlayReady = CrklOverlayBridge.isReady()
|
overlayReady = CrklOverlayBridge.isReady()
|
||||||
@@ -120,8 +145,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
startActivity(Intent(this, IntegrationsActivity::class.java))
|
startActivity(Intent(this, IntegrationsActivity::class.java))
|
||||||
},
|
},
|
||||||
onOpenTestFixtures = {
|
onOpenTestFixtures = {
|
||||||
settings.onboardingComplete = true
|
|
||||||
onboardingDone = true
|
|
||||||
startActivity(Intent(this, TestFixturesActivity::class.java))
|
startActivity(Intent(this, TestFixturesActivity::class.java))
|
||||||
},
|
},
|
||||||
onEnableCapture = {
|
onEnableCapture = {
|
||||||
@@ -130,9 +153,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
captureReady = MediaProjectionHolder.isReady()
|
captureReady = MediaProjectionHolder.isReady()
|
||||||
}, 1500)
|
}, 1500)
|
||||||
},
|
},
|
||||||
onSkipOnboarding = {
|
onSkipQuest = {
|
||||||
settings.onboardingComplete = true
|
CircleQuest.skip(settings)
|
||||||
onboardingDone = true
|
onboardingDone = true
|
||||||
|
questStep = CircleQuest.DONE
|
||||||
},
|
},
|
||||||
onAddQsTile = { requestQsTile() },
|
onAddQsTile = { requestQsTile() },
|
||||||
canAddQsTile = Build.VERSION.SDK_INT >= 33,
|
canAddQsTile = Build.VERSION.SDK_INT >= 33,
|
||||||
@@ -208,13 +232,14 @@ fun MainScreen(
|
|||||||
captureReady: Boolean,
|
captureReady: Boolean,
|
||||||
integrationHint: String,
|
integrationHint: String,
|
||||||
accessibilityEnabled: Boolean,
|
accessibilityEnabled: Boolean,
|
||||||
showOnboarding: Boolean,
|
showQuest: Boolean,
|
||||||
|
questStep: Int,
|
||||||
onRefreshA11y: () -> Unit,
|
onRefreshA11y: () -> Unit,
|
||||||
onOpenAccessibilitySettings: () -> Unit,
|
onOpenAccessibilitySettings: () -> Unit,
|
||||||
onOpenIntegrations: () -> Unit,
|
onOpenIntegrations: () -> Unit,
|
||||||
onOpenTestFixtures: () -> Unit,
|
onOpenTestFixtures: () -> Unit,
|
||||||
onEnableCapture: () -> Unit,
|
onEnableCapture: () -> Unit,
|
||||||
onSkipOnboarding: () -> Unit,
|
onSkipQuest: () -> Unit,
|
||||||
onAddQsTile: () -> Unit = {},
|
onAddQsTile: () -> Unit = {},
|
||||||
canAddQsTile: Boolean = false,
|
canAddQsTile: Boolean = false,
|
||||||
overlayReady: Boolean = false
|
overlayReady: Boolean = false
|
||||||
@@ -278,7 +303,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showOnboarding) {
|
if (showQuest) {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -286,46 +311,52 @@ fun MainScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(
|
||||||
Text("Get started", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Three steps — then circle anything on screen.",
|
CircleQuest.title(questStep),
|
||||||
style = MaterialTheme.typography.bodySmall
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
OnboardStep(
|
Text(
|
||||||
number = 1,
|
CircleQuest.dots(questStep),
|
||||||
title = "Enable Accessibility",
|
style = MaterialTheme.typography.titleLarge,
|
||||||
done = accessibilityEnabled,
|
color = MaterialTheme.colorScheme.primary
|
||||||
actionLabel = if (accessibilityEnabled) "Enabled" else "Open settings",
|
|
||||||
onAction = {
|
|
||||||
onOpenAccessibilitySettings()
|
|
||||||
onRefreshA11y()
|
|
||||||
},
|
|
||||||
enabled = !accessibilityEnabled
|
|
||||||
)
|
)
|
||||||
OnboardStep(
|
Text(
|
||||||
number = 2,
|
CircleQuest.prompt(questStep),
|
||||||
title = "Integrations (optional)",
|
style = MaterialTheme.typography.bodyMedium
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onSkipOnboarding) {
|
TextButton(onClick = onSkipQuest) {
|
||||||
Text("Skip for now")
|
Text("Skip for now")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +428,7 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Panel chips: Translate · Copy · Explain · Share · Vikunja",
|
text = "Panel chips: Translate · Copy · Explain · Read · Share · Todo · ?",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -405,32 +436,3 @@ fun MainScreen(
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
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
|
package com.example.crkl.accessibility
|
||||||
|
|
||||||
import android.accessibilityservice.AccessibilityService
|
import android.accessibilityservice.AccessibilityService
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
@@ -11,6 +12,7 @@ import android.util.Log
|
|||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.accessibility.AccessibilityEvent
|
import android.view.accessibility.AccessibilityEvent
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.example.crkl.agent.ActionHints
|
import com.example.crkl.agent.ActionHints
|
||||||
import com.example.crkl.agent.ActionExecutor
|
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.DeviceTts
|
||||||
import com.example.crkl.model.MediaPipeLocalLlm
|
import com.example.crkl.model.MediaPipeLocalLlm
|
||||||
import com.example.crkl.integrations.IntegrationSettings
|
import com.example.crkl.integrations.IntegrationSettings
|
||||||
|
import com.example.crkl.onboarding.CircleQuest
|
||||||
import com.example.crkl.ui.ResultPanelView
|
import com.example.crkl.ui.ResultPanelView
|
||||||
import com.example.crkl.vision.ContentCapture
|
import com.example.crkl.vision.ContentCapture
|
||||||
import com.example.crkl.vision.ScreenOcr
|
import com.example.crkl.vision.ScreenOcr
|
||||||
@@ -32,7 +35,6 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System overlay + selection pipeline.
|
* System overlay + selection pipeline.
|
||||||
*
|
*
|
||||||
@@ -233,6 +235,7 @@ class CrklAccessibilityService : AccessibilityService() {
|
|||||||
private fun handleSelection(bounds: RectF) {
|
private fun handleSelection(bounds: RectF) {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
overlayView?.exitOverlayMode()
|
overlayView?.exitOverlayMode()
|
||||||
|
noteQuestWin(CircleQuest.Win.CIRCLE)
|
||||||
showResultPanel(
|
showResultPanel(
|
||||||
AssistEngine.AssistResponse(
|
AssistEngine.AssistResponse(
|
||||||
title = "Reading…",
|
title = "Reading…",
|
||||||
@@ -377,11 +380,18 @@ class CrklAccessibilityService : AccessibilityService() {
|
|||||||
onListen = { startSttFromPanel() },
|
onListen = { startSttFromPanel() },
|
||||||
onShareList = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.SHARE_LIST, raw = "share")) },
|
onShareList = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.SHARE_LIST, raw = "share")) },
|
||||||
onAddTodo = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.ADD_TO_TODO, raw = "add to todo")) },
|
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")) },
|
onCopy = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.COPY, raw = "copy")) },
|
||||||
onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) },
|
onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) },
|
||||||
onReadAloud = { readAloudFromPanel() },
|
onReadAloud = {
|
||||||
onIdeas = { showIdeasFromPanel() }
|
readAloudFromPanel()
|
||||||
|
noteQuestWin(CircleQuest.Win.ACTION)
|
||||||
|
},
|
||||||
|
onIdeas = { showIdeasFromPanel() },
|
||||||
|
onChipLongPress = { noteQuestWin(CircleQuest.Win.HOLD_ICON) }
|
||||||
)
|
)
|
||||||
panel.showResult(response.title, response.meta, response.body)
|
panel.showResult(response.title, response.meta, response.body)
|
||||||
resultPanel = panel
|
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() {
|
private fun readAloudFromPanel() {
|
||||||
val body = resultPanel?.currentBody().orEmpty()
|
val body = resultPanel?.currentBody().orEmpty()
|
||||||
val speakable = body
|
val speakable = body
|
||||||
|
|||||||
@@ -342,25 +342,38 @@ class OverlayView(
|
|||||||
private fun drawOverlayMode(canvas: Canvas) {
|
private fun drawOverlayMode(canvas: Canvas) {
|
||||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimPaint)
|
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 failing = System.currentTimeMillis() < failFlashUntil
|
||||||
val pillW = dp(280f)
|
val showHint = failing || (!isDrawing && touchPath.isEmpty())
|
||||||
val pillH = dp(38f)
|
if (showHint) {
|
||||||
val pillTop = statusBarInset() + dp(12f)
|
val label = if (failing) hintMessage else "Circle text · Cancel →"
|
||||||
val pill = RectF(
|
val pillW = instructionPaint.measureText(label) + dp(28f)
|
||||||
width / 2f - pillW / 2f,
|
val pillH = dp(32f)
|
||||||
pillTop,
|
val pillTop = statusBarInset() + dp(8f)
|
||||||
width / 2f + pillW / 2f,
|
val pill = RectF(
|
||||||
pillTop + pillH
|
width / 2f - pillW / 2f,
|
||||||
)
|
pillTop,
|
||||||
canvas.drawRoundRect(pill, dp(19f), dp(19f), hintPillPaint)
|
width / 2f + pillW / 2f,
|
||||||
canvas.drawRoundRect(pill, dp(19f), dp(19f), hintRingPaint)
|
pillTop + pillH
|
||||||
instructionPaint.color = if (failing) Color.parseColor("#FFCFC4") else Color.WHITE
|
)
|
||||||
canvas.drawText(
|
// Keep clear of Cancel on the right
|
||||||
hintMessage,
|
if (pill.right > width - dp(110f)) {
|
||||||
pill.centerX(),
|
pill.offset(width - dp(110f) - pill.right, 0f)
|
||||||
pill.centerY() + instructionPaint.textSize / 3f,
|
}
|
||||||
instructionPaint
|
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()
|
updateExitRect()
|
||||||
canvas.drawRoundRect(exitRect, dp(18f), dp(18f), exitBgPaint)
|
canvas.drawRoundRect(exitRect, dp(18f), dp(18f), exitBgPaint)
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class IntegrationSettings(
|
|||||||
get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
|
get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_ONBOARDING_DONE, value).apply()
|
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). */
|
/** Show capture source / OCR counts on the result panel (for debugging). */
|
||||||
var showDebugMeta: Boolean
|
var showDebugMeta: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SHOW_DEBUG_META, false)
|
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_CIRCLE_STROKE = "circle_stroke"
|
||||||
private const val KEY_TRANSLATE_TARGET = "translate_target"
|
private const val KEY_TRANSLATE_TARGET = "translate_target"
|
||||||
private const val KEY_ONBOARDING_DONE = "onboarding_done"
|
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"
|
private const val KEY_SHOW_DEBUG_META = "show_debug_meta"
|
||||||
|
|
||||||
const val DEFAULT_VIKUNJA_URL = "https://todo.levkin.ca"
|
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 onCopy: (() -> Unit)? = null,
|
||||||
private val onExplain: (() -> Unit)? = null,
|
private val onExplain: (() -> Unit)? = null,
|
||||||
private val onReadAloud: (() -> Unit)? = null,
|
private val onReadAloud: (() -> Unit)? = null,
|
||||||
private val onIdeas: (() -> Unit)? = null
|
private val onIdeas: (() -> Unit)? = null,
|
||||||
|
private val onChipLongPress: (() -> Unit)? = null
|
||||||
) : LinearLayout(context) {
|
) : LinearLayout(context) {
|
||||||
|
|
||||||
private val titleView: TextView
|
private val titleView: TextView
|
||||||
@@ -142,18 +143,19 @@ class ResultPanelView(
|
|||||||
bg: Int,
|
bg: Int,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
): ImageButton {
|
): 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 {
|
return ImageButton(context).apply {
|
||||||
setImageResource(iconRes)
|
setImageResource(iconRes)
|
||||||
imageTintList = android.content.res.ColorStateList.valueOf(CrklUi.ChipFg)
|
imageTintList = android.content.res.ColorStateList.valueOf(CrklUi.ChipFg)
|
||||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
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 {
|
background = GradientDrawable().apply {
|
||||||
setColor(bg)
|
setColor(bg)
|
||||||
cornerRadius = dp(22).toFloat()
|
cornerRadius = dp(20).toFloat()
|
||||||
}
|
}
|
||||||
layoutParams = LayoutParams(size, size).apply {
|
layoutParams = LayoutParams(size, size).apply {
|
||||||
marginEnd = dp(8)
|
marginEnd = dp(6)
|
||||||
}
|
}
|
||||||
isClickable = true
|
isClickable = true
|
||||||
isFocusable = true
|
isFocusable = true
|
||||||
@@ -162,6 +164,7 @@ class ResultPanelView(
|
|||||||
setOnLongClickListener {
|
setOnLongClickListener {
|
||||||
android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT)
|
android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT)
|
||||||
.show()
|
.show()
|
||||||
|
onChipLongPress?.invoke()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
|
|||||||
@@ -32,4 +32,14 @@ class IntegrationSettingsTest {
|
|||||||
s.onboardingComplete = true
|
s.onboardingComplete = true
|
||||||
assertTrue(s.onboardingComplete)
|
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 |
|
| 1 | “Circle anything with the floating **C**” | First closed loop |
|
||||||
| 2 | “Hold an icon — learn its name” | Long-press toast |
|
| 2 | “Hold an icon — learn its name” | Long-press toast |
|
||||||
| 3 | “Try **Read aloud** or **Translate**” | One action |
|
| 3 | “Try **Read aloud** or **Translate**” | One of those actions |
|
||||||
| 4 | Optional: “Open fixtures → email → Todo” | Vikunja configured |
|
|
||||||
|
Optional later: fixtures → email → Todo (Vikunja) — not part of the gated quest.
|
||||||
|
|
||||||
## Tone
|
## Tone
|
||||||
|
|
||||||
- Short, cheeky copy (“Nice loop.” / “You’re circling.”)
|
- Short, cheeky copy (“Nice loop.” / “You’re circling.”)
|
||||||
- Progress dots (1/3 · 2/3 · 3/3), not a points economy
|
- Progress dots (○ ○ ○ → ● ● ●), not a points economy
|
||||||
- Skip always available; never block Overlay enable
|
- **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
|
## Avoid
|
||||||
|
|
||||||
- Streaks / leaderboards / XP (wrong product)
|
- Streaks / leaderboards / XP
|
||||||
- Multi-screen wizard before Accessibility is on
|
- 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>-avd</string><string>${AVD_NAME}</string>
|
||||||
<string>-memory</string><string>3072</string>
|
<string>-memory</string><string>3072</string>
|
||||||
<string>-cores</string><string>4</string>
|
<string>-cores</string><string>4</string>
|
||||||
<string>-no-audio</string>
|
|
||||||
<string>-gpu</string><string>${GPU}</string>
|
<string>-gpu</string><string>${GPU}</string>
|
||||||
<string>-accel</string><string>on</string>
|
<string>-accel</string><string>on</string>
|
||||||
<string>-no-snapshot-load</string>
|
<string>-no-snapshot-load</string>
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ ensure_emulator() {
|
|||||||
chmod +x "$ROOT/scripts/start-emulator-launchd.sh"
|
chmod +x "$ROOT/scripts/start-emulator-launchd.sh"
|
||||||
bash "$ROOT/scripts/start-emulator-launchd.sh"
|
bash "$ROOT/scripts/start-emulator-launchd.sh"
|
||||||
else
|
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 \
|
-gpu "$GPU" -accel on -no-snapshot-load -no-metrics \
|
||||||
-grpc 8554 \
|
-grpc 8554 \
|
||||||
>"$LOG" 2>&1 &
|
>"$LOG" 2>&1 &
|
||||||
|
|||||||
Reference in New Issue
Block a user