diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f48fb3..248c076 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 1.16.7-read-ideas — 2026-08-05
+
+- Long-press any action icon → name toast
+- Material-style Translate / Share / Copy / Explain icons
+- **Read aloud** (on-device TTS) + **What can I do?** contextual hints
+- Docs: playful Circle quest onboarding sketch (`docs/onboarding-quest.md`)
+
## 1.16.6-icons-perf — 2026-08-05
- VIP actions are icon-only (Todo = checkbox with checkmark)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dd13a38..994fd4d 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 = 23
- versionName = "1.16.6-icons-perf"
+ versionCode = 24
+ versionName = "1.16.7-read-ideas"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
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 63933da..0b850c5 100644
--- a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt
+++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt
@@ -12,11 +12,13 @@ import android.view.Gravity
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
import androidx.core.content.ContextCompat
+import com.example.crkl.agent.ActionHints
import com.example.crkl.agent.ActionExecutor
import com.example.crkl.agent.AssistEngine
import com.example.crkl.agent.VoiceIntent
import com.example.crkl.media.MediaAssistPipeline
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.ui.ResultPanelView
@@ -48,6 +50,7 @@ class CrklAccessibilityService : AccessibilityService() {
private lateinit var localLlm: MediaPipeLocalLlm
private lateinit var assistEngine: AssistEngine
private lateinit var localStt: DeviceSpeechStt
+ private lateinit var localTts: DeviceTts
private lateinit var actionExecutor: ActionExecutor
private var lastSession: ActionExecutor.Session? = null
@@ -57,6 +60,7 @@ class CrklAccessibilityService : AccessibilityService() {
localLlm = MediaPipeLocalLlm(this)
assistEngine = AssistEngine(localLlm)
localStt = DeviceSpeechStt(this)
+ localTts = DeviceTts(this)
actionExecutor = ActionExecutor(
context = this,
explainText = { text -> assistEngine.explain(text) }
@@ -366,13 +370,18 @@ class CrklAccessibilityService : AccessibilityService() {
dismissResultPanel()
val panel = ResultPanelView(
context = this,
- onDismiss = { dismissResultPanel() },
+ onDismiss = {
+ localTts.stop()
+ dismissResultPanel()
+ },
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")) },
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() },
+ onIdeas = { showIdeasFromPanel() }
)
panel.showResult(response.title, response.meta, response.body)
resultPanel = panel
@@ -395,6 +404,36 @@ class CrklAccessibilityService : AccessibilityService() {
}
}
+ private fun readAloudFromPanel() {
+ val body = resultPanel?.currentBody().orEmpty()
+ val speakable = body
+ .lineSequence()
+ .map { it.trim() }
+ .filter { line ->
+ line.isNotEmpty() &&
+ line != "—" &&
+ !line.startsWith("Tip:", ignoreCase = true) &&
+ !line.startsWith("Commands:", ignoreCase = true)
+ }
+ .joinToString(" ")
+ .ifBlank { body }
+ if (speakable.isBlank()) {
+ resultPanel?.appendBody("Nothing to read yet.")
+ return
+ }
+ if (!localTts.isAvailable) {
+ // TTS init is async — still try; engine queues after ready on most devices.
+ Log.w(tagName, "TTS not marked ready yet — attempting speak")
+ }
+ localTts.speak(speakable)
+ resultPanel?.appendBody("Reading aloud…")
+ }
+
+ private fun showIdeasFromPanel() {
+ val body = resultPanel?.currentBody().orEmpty()
+ resultPanel?.appendBody("—\n${ActionHints.forText(body)}")
+ }
+
private fun startSttFromPanel() {
val micGranted = ContextCompat.checkSelfPermission(
this,
@@ -466,6 +505,9 @@ class CrklAccessibilityService : AccessibilityService() {
if (::localStt.isInitialized) {
localStt.cancel()
}
+ if (::localTts.isInitialized) {
+ localTts.stop()
+ }
resultPanel?.let { panel ->
try {
windowManager?.removeView(panel)
@@ -494,6 +536,7 @@ class CrklAccessibilityService : AccessibilityService() {
overlayView = null
}
if (::localLlm.isInitialized) localLlm.close()
+ if (::localTts.isInitialized) localTts.shutdown()
serviceScope.cancel()
Log.d(tagName, "Service destroyed")
}
diff --git a/app/src/main/kotlin/com/example/crkl/agent/ActionHints.kt b/app/src/main/kotlin/com/example/crkl/agent/ActionHints.kt
new file mode 100644
index 0000000..97dbd4f
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/agent/ActionHints.kt
@@ -0,0 +1,46 @@
+package com.example.crkl.agent
+
+/**
+ * Contextual “What can I do with this?” hints for the result panel.
+ */
+object ActionHints {
+
+ fun forText(raw: String): String {
+ val text = raw.trim()
+ if (text.isBlank()) {
+ return "Circle some text first — then I can translate, copy, read it aloud, or add a todo."
+ }
+ val lower = text.lowercase()
+ val lines = buildList {
+ add("With this selection you can:")
+ add("• Read aloud — hear it spoken on-device")
+ add("• Translate — another language without leaving the screen")
+ add("• Copy — paste anywhere")
+ when {
+ "subject:" in lower || "from:" in lower -> {
+ add("• Todo — drop the subject into Vikunja")
+ add("• Explain — plain-language summary of the mail")
+ add("• Share — send the snippet onward")
+ }
+ text.lineSequence().count {
+ val t = it.trim()
+ t.startsWith("•") || t.startsWith("-") || t.matches(Regex("""\d+\..*"""))
+ } >= 2 -> {
+ add("• Todo — turn the list into Vikunja tasks")
+ add("• Share — send the list")
+ }
+ text.length < 48 -> {
+ add("• Explain — what this phrase means")
+ add("• Share — pass the phrase along")
+ }
+ else -> {
+ add("• Explain — shorter reading")
+ add("• Todo / Share — save or send")
+ }
+ }
+ add("")
+ add("Tip: press and hold any icon to see its name.")
+ }
+ return lines.joinToString("\n")
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/DeviceTts.kt b/app/src/main/kotlin/com/example/crkl/model/DeviceTts.kt
new file mode 100644
index 0000000..8709311
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/DeviceTts.kt
@@ -0,0 +1,53 @@
+package com.example.crkl.model
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import android.util.Log
+import java.util.Locale
+
+/**
+ * On-device read-aloud via [TextToSpeech]. No network.
+ */
+class DeviceTts(context: Context) {
+
+ private val tagName = "DeviceTts"
+ private val appContext = context.applicationContext
+ @Volatile
+ private var ready = false
+ private var tts: TextToSpeech? = null
+
+ init {
+ tts = TextToSpeech(appContext) { status ->
+ ready = status == TextToSpeech.SUCCESS
+ if (ready) {
+ tts?.language = Locale.getDefault()
+ Log.d(tagName, "TTS ready lang=${Locale.getDefault()}")
+ } else {
+ Log.w(tagName, "TTS init failed status=$status")
+ }
+ }
+ }
+
+ val isAvailable: Boolean
+ get() = ready
+
+ fun speak(text: String) {
+ val engine = tts ?: return
+ val cleaned = text.trim().take(3_500)
+ if (cleaned.isBlank()) return
+ engine.stop()
+ // Even if ready flag lags, speak() is safe — engine queues after init.
+ engine.speak(cleaned, TextToSpeech.QUEUE_FLUSH, null, "circle-read")
+ }
+
+ fun stop() {
+ tts?.stop()
+ }
+
+ fun shutdown() {
+ tts?.stop()
+ tts?.shutdown()
+ tts = null
+ ready = false
+ }
+}
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 eedfc5c..72ada3d 100644
--- a/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt
+++ b/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt
@@ -18,7 +18,7 @@ import android.widget.TextView
import com.example.crkl.R
/**
- * Bottom-sheet style result card: title, text, five VIP chips, quiet status.
+ * Bottom-sheet style result card: title, text, icon actions, quiet status.
*/
class ResultPanelView(
context: Context,
@@ -28,7 +28,9 @@ class ResultPanelView(
private val onAddTodo: (() -> Unit)? = null,
private val onTranslate: (() -> Unit)? = null,
private val onCopy: (() -> Unit)? = null,
- private val onExplain: (() -> Unit)? = null
+ private val onExplain: (() -> Unit)? = null,
+ private val onReadAloud: (() -> Unit)? = null,
+ private val onIdeas: (() -> Unit)? = null
) : LinearLayout(context) {
private val titleView: TextView
@@ -156,6 +158,12 @@ class ResultPanelView(
isClickable = true
isFocusable = true
contentDescription = label
+ // Hold to learn the icon name (overlay has no system tooltip chrome).
+ setOnLongClickListener {
+ android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT)
+ .show()
+ true
+ }
setOnClickListener {
statusView.visibility = VISIBLE
statusView.setTextColor(CrklUi.Mist)
@@ -165,7 +173,7 @@ class ResultPanelView(
}
}
- // VIP only — Email / Calendar stay on voice
+ // Order: core → hear → share/save → discover
if (onTranslate != null) {
actionsRow.addView(
actionChip("Translate", R.drawable.ic_chip_translate, CrklUi.TealDeep, onTranslate)
@@ -181,6 +189,11 @@ class ResultPanelView(
actionChip("Explain", R.drawable.ic_chip_explain, CrklUi.Ink, onExplain)
)
}
+ if (onReadAloud != null) {
+ actionsRow.addView(
+ actionChip("Read aloud", R.drawable.ic_chip_read, CrklUi.Teal, onReadAloud)
+ )
+ }
if (onShareList != null) {
actionsRow.addView(
actionChip("Share", R.drawable.ic_chip_share, CrklUi.Mist, onShareList)
@@ -191,6 +204,11 @@ class ResultPanelView(
actionChip("Todo", R.drawable.ic_chip_todo, CrklUi.Ok, onAddTodo)
)
}
+ if (onIdeas != null) {
+ actionsRow.addView(
+ actionChip("What can I do?", R.drawable.ic_chip_ideas, CrklUi.InkSoft, onIdeas)
+ )
+ }
val actionsScroll = object : HorizontalScrollView(context) {
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
@@ -263,6 +281,9 @@ class ResultPanelView(
if (!entered) playEnter()
}
+ /** Text currently shown — for Read aloud / Ideas. */
+ fun currentBody(): String = bodyView.text?.toString().orEmpty()
+
fun appendBody(extra: String) {
val current = bodyView.text?.toString().orEmpty()
bodyView.text = if (current.isBlank()) extra else "$current\n\n$extra"
diff --git a/app/src/main/res/drawable/ic_chip_copy.xml b/app/src/main/res/drawable/ic_chip_copy.xml
index 9d72852..e5e3d7d 100644
--- a/app/src/main/res/drawable/ic_chip_copy.xml
+++ b/app/src/main/res/drawable/ic_chip_copy.xml
@@ -1,4 +1,5 @@
+
+ android:pathData="M16,1H4c-1.1,0 -2,0.9 -2,2v14h2V3h12V1zM19,5H8c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2zM19,21H8V7h11v14z" />
diff --git a/app/src/main/res/drawable/ic_chip_explain.xml b/app/src/main/res/drawable/ic_chip_explain.xml
index 3d6b044..8a58fa3 100644
--- a/app/src/main/res/drawable/ic_chip_explain.xml
+++ b/app/src/main/res/drawable/ic_chip_explain.xml
@@ -1,4 +1,5 @@
+
+ android:pathData="M9,21c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-1H9v1zM12,2C8.14,2 5,5.14 5,9c0,2.38 1.19,4.47 3,5.74V17c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-2.26c1.81,-1.27 3,-3.36 3,-5.74 0,-3.86 -3.14,-7 -7,-7z" />
diff --git a/app/src/main/res/drawable/ic_chip_ideas.xml b/app/src/main/res/drawable/ic_chip_ideas.xml
new file mode 100644
index 0000000..49bc947
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chip_ideas.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_chip_read.xml b/app/src/main/res/drawable/ic_chip_read.xml
new file mode 100644
index 0000000..4946c50
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chip_read.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_chip_share.xml b/app/src/main/res/drawable/ic_chip_share.xml
index 6a49a02..941c87d 100644
--- a/app/src/main/res/drawable/ic_chip_share.xml
+++ b/app/src/main/res/drawable/ic_chip_share.xml
@@ -1,4 +1,5 @@
+
+ android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
diff --git a/app/src/main/res/drawable/ic_chip_translate.xml b/app/src/main/res/drawable/ic_chip_translate.xml
index ad7686c..dd1978a 100644
--- a/app/src/main/res/drawable/ic_chip_translate.xml
+++ b/app/src/main/res/drawable/ic_chip_translate.xml
@@ -1,14 +1,11 @@
+
-
-
+ android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03A17.5,17.5 0 0 0 14.07,6H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
diff --git a/app/src/test/java/com/example/crkl/agent/ActionHintsTest.kt b/app/src/test/java/com/example/crkl/agent/ActionHintsTest.kt
new file mode 100644
index 0000000..8c7d565
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/agent/ActionHintsTest.kt
@@ -0,0 +1,23 @@
+package com.example.crkl.agent
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ActionHintsTest {
+
+ @Test
+ fun email_suggestsTodoAndRead() {
+ val hints = ActionHints.forText(
+ "From: alex@example.com\nSubject: Q2 planning moved\nHi there"
+ )
+ assertTrue(hints.contains("Read aloud"))
+ assertTrue(hints.contains("Todo"))
+ assertTrue(hints.contains("press and hold", ignoreCase = true))
+ }
+
+ @Test
+ fun empty_promptsCircleFirst() {
+ val hints = ActionHints.forText(" ")
+ assertTrue(hints.contains("Circle some text", ignoreCase = true))
+ }
+}
diff --git a/docs/onboarding-quest.md b/docs/onboarding-quest.md
new file mode 100644
index 0000000..09fe295
--- /dev/null
+++ b/docs/onboarding-quest.md
@@ -0,0 +1,27 @@
+# Playful onboarding (Circle quest)
+
+Goal: first-run feels like a tiny game, not a settings dump.
+
+## Loop (≈90s)
+
+| Step | Prompt | Win |
+|------|--------|-----|
+| 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 |
+
+## 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
+
+## Avoid
+
+- Streaks / leaderboards / XP (wrong product)
+- 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).