From 2749ec0610bd815c7419d8d28658d63643ba5d44 Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 5 Aug 2026 14:51:52 -0400 Subject: [PATCH] Add read aloud, action ideas, and clearer icons Long-press shows icon names; Material-style translate/share icons; on-device TTS read-aloud and contextual What can I do hints. --- CHANGELOG.md | 7 +++ app/build.gradle.kts | 4 +- .../accessibility/CrklAccessibilityService.kt | 47 +++++++++++++++- .../com/example/crkl/agent/ActionHints.kt | 46 ++++++++++++++++ .../com/example/crkl/model/DeviceTts.kt | 53 +++++++++++++++++++ .../com/example/crkl/ui/ResultPanelView.kt | 27 ++++++++-- app/src/main/res/drawable/ic_chip_copy.xml | 3 +- app/src/main/res/drawable/ic_chip_explain.xml | 3 +- app/src/main/res/drawable/ic_chip_ideas.xml | 11 ++++ app/src/main/res/drawable/ic_chip_read.xml | 11 ++++ app/src/main/res/drawable/ic_chip_share.xml | 3 +- .../main/res/drawable/ic_chip_translate.xml | 7 +-- .../com/example/crkl/agent/ActionHintsTest.kt | 23 ++++++++ docs/onboarding-quest.md | 27 ++++++++++ 14 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 app/src/main/kotlin/com/example/crkl/agent/ActionHints.kt create mode 100644 app/src/main/kotlin/com/example/crkl/model/DeviceTts.kt create mode 100644 app/src/main/res/drawable/ic_chip_ideas.xml create mode 100644 app/src/main/res/drawable/ic_chip_read.xml create mode 100644 app/src/test/java/com/example/crkl/agent/ActionHintsTest.kt create mode 100644 docs/onboarding-quest.md 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). -- 2.49.1