Add read aloud, action ideas, and clearer icons
CI / skip-ci-check (pull_request) Successful in 30s
CI / secret-scan (pull_request) Successful in 29s

Long-press shows icon names; Material-style translate/share icons;
on-device TTS read-aloud and contextual What can I do hints.
This commit is contained in:
2026-08-05 14:51:52 -04:00
parent c7fa37188b
commit 2749ec0610
14 changed files with 257 additions and 15 deletions
+7
View File
@@ -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)
+2 -2
View File
@@ -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 {
@@ -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")
}
@@ -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")
}
}
@@ -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
}
}
@@ -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"
+2 -1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material content_copy -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -6,5 +7,5 @@
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,4h10c1.1,0 2,0.9 2,2v14l-3,-2 -3,2 -3,-2 -3,2V6c0,-1.1 0.9,-2 2,-2zM9,8h6v2H9V8zM9,12h6v2H9v-2z" />
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" />
</vector>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material lightbulb -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -6,5 +7,5 @@
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3a7,7 0 0 1 7,7c0,2.6 -1.4,4.4 -3.2,6.1 -0.7,0.7 -1.4,1.3 -1.9,2.1h-3.8c-0.5,-0.8 -1.2,-1.4 -1.9,-2.1C6.4,14.4 5,12.6 5,10a7,7 0 0 1 7,-7zM10,20h4v2h-4v-2z" />
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" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material help_outline — What can I do -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2H8c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material volume_up — Read aloud -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z" />
</vector>
+2 -1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material share (three nodes) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -6,5 +7,5 @@
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14,4v2h3.6L12,11.6 6.4,6H10V4H4v6h2V7.4L12,13.4 18,7.4V10h2V4h-6zM5,16h14v2H5v-2zM5,20h10v2H5v-2z" />
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" />
</vector>
@@ -1,14 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material-style g_translate: Latin A + CJK mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Simple A⇄文 mark -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3,6h6v2H5.5l2.2,6h2.1L12,8h2l-3.2,9H8.6L6.5,11.2 4.4,17H2.2L5.4,8H3V6z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14,7h7v2h-2.1l-0.9,2.4h2.2v2h-2.9L16,17h-2.2l1.5-3.6H13V11h1.9L16,7z" />
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" />
</vector>
@@ -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))
}
}
+27
View File
@@ -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.” / “Youre 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).