<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Jetpack Compose on MangoDriod</title><link>https://md.eknath.dev/tags/jetpack-compose/</link><description>Recent content in Jetpack Compose on MangoDriod</description><generator>Hugo -- 0.141.0</generator><language>en-us</language><lastBuildDate>Thu, 12 Feb 2026 21:30:00 +0530</lastBuildDate><atom:link href="https://md.eknath.dev/tags/jetpack-compose/index.xml" rel="self" type="application/rss+xml"/><item><title>Building a Reusable Speech-to-Text Component in Jetpack Compose</title><link>https://md.eknath.dev/posts/jetpack-compose-speech-to-text-implementation/</link><pubDate>Thu, 12 Feb 2026 21:30:00 +0530</pubDate><guid>https://md.eknath.dev/posts/jetpack-compose-speech-to-text-implementation/</guid><description>&lt;h2 id="tldr---why-you-should-add-voice-input">TL;DR - Why You Should Add Voice Input&lt;/h2>
&lt;p>&lt;strong>Voice input can dramatically improve UX, yet most apps don&amp;rsquo;t use it.&lt;/strong> Here&amp;rsquo;s why you should:&lt;/p>
&lt;p>✅ &lt;strong>Zero app size increase&lt;/strong> - Uses Android&amp;rsquo;s native speech recognition (no libraries!)
✅ &lt;strong>No permissions required&lt;/strong> - Works out of the box
✅ &lt;strong>3-5x faster input&lt;/strong> - Users can speak 150+ words/min vs typing 40 words/min
✅ &lt;strong>Better accessibility&lt;/strong> - Essential for users with motor impairments
✅ &lt;strong>Reduces friction&lt;/strong> - One tap vs multiple keyboard interactions
✅ &lt;strong>Professional polish&lt;/strong> - Shows attention to UX details&lt;/p></description><content:encoded><![CDATA[<h2 id="tldr---why-you-should-add-voice-input">TL;DR - Why You Should Add Voice Input</h2>
<p><strong>Voice input can dramatically improve UX, yet most apps don&rsquo;t use it.</strong> Here&rsquo;s why you should:</p>
<p>✅ <strong>Zero app size increase</strong> - Uses Android&rsquo;s native speech recognition (no libraries!)
✅ <strong>No permissions required</strong> - Works out of the box
✅ <strong>3-5x faster input</strong> - Users can speak 150+ words/min vs typing 40 words/min
✅ <strong>Better accessibility</strong> - Essential for users with motor impairments
✅ <strong>Reduces friction</strong> - One tap vs multiple keyboard interactions
✅ <strong>Professional polish</strong> - Shows attention to UX details</p>
<p><strong>The catch?</strong> It requires network connectivity and device support. But with proper availability checks, you can gracefully hide the feature when unavailable—making it a <strong>pure win</strong> when present.</p>
<hr>
<h2 id="why-most-apps-skip-this-feature">Why Most Apps Skip This Feature</h2>
<p>Despite being a <strong>native Android capability since API 8</strong>, many developers overlook voice input because:</p>
<ol>
<li><strong>Assumed complexity</strong> - Developers think it requires heavy ML libraries</li>
<li><strong>Unclear implementation</strong> - Documentation is scattered</li>
<li><strong>Network dependency concerns</strong> - Fear of handling edge cases</li>
<li><strong>Device fragmentation worries</strong> - Uncertainty about availability</li>
<li><strong>&ldquo;The keyboard already has it&rdquo;</strong> - The most common misconception</li>
</ol>
<p><strong>The truth?</strong> It&rsquo;s simpler than adding a date picker, and this guide shows you how to handle all edge cases properly.</p>
<hr>
<h2 id="but-users-have-voice-input-on-their-keyboard-already">&ldquo;But Users Have Voice Input on Their Keyboard Already!&rdquo;</h2>
<p>This is the <strong>most common objection</strong> developers raise. Yes, most mobile keyboards (Gboard, SwiftKey, Samsung Keyboard) have a mic button. <strong>But here&rsquo;s why in-app voice input is still essential:</strong></p>
<h3 id="the-reality-of-keyboard-voice-input-usage"><strong>The Reality of Keyboard Voice Input Usage</strong></h3>
<p>📊 <strong>Usage statistics show a problem:</strong></p>
<ul>
<li>Most users <strong>don&rsquo;t even know</strong> the keyboard mic button exists</li>
<li>Many <strong>forget about it</strong> after the initial setup</li>
<li>Some <strong>disable it accidentally</strong> during keyboard customization</li>
<li>The keyboard mic is <strong>visually small</strong> and easy to miss</li>
<li>Users must <strong>actively look for it</strong> among other keyboard buttons</li>
</ul>
<h3 id="why-in-app-voice-input-is-superior"><strong>Why In-App Voice Input is Superior</strong></h3>
<h4 id="1-discoverability"><strong>1. Discoverability</strong></h4>
<pre tabindex="0"><code>❌ Keyboard mic: Hidden among 30+ keyboard keys, looks like any other button
✅ In-app mic: Prominent, contextual, right next to the input field
</code></pre><p><strong>Example:</strong> A text field with a mic icon in the trailing position is <strong>immediately obvious</strong>. The keyboard mic? Users have to open the keyboard, scan for it, and remember it exists.</p>
<h4 id="2-context-aware-ux"><strong>2. Context-Aware UX</strong></h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#75715e">// In-app voice can be contextual
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>TextField(
</span></span><span style="display:flex;"><span>    label = { Text(<span style="color:#e6db74">&#34;Product Review&#34;</span>) },
</span></span><span style="display:flex;"><span>    trailingIcon = { MicIcon() }  <span style="color:#75715e">// Clear purpose: &#34;Speak your review&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>)
</span></span></code></pre></div><p>The keyboard mic has <strong>no context</strong> - it&rsquo;s the same button whether you&rsquo;re entering an email, a password, or a product review. In-app voice input can show <strong>field-specific prompts</strong> like &ldquo;Describe your issue&rdquo; or &ldquo;Speak your address&rdquo;.</p>
<h4 id="3-user-intent-and-flow"><strong>3. User Intent and Flow</strong></h4>
<ul>
<li>
<p><strong>Keyboard mic</strong>: Requires users to:</p>
<ol>
<li>Tap the input field</li>
<li>Wait for keyboard to appear</li>
<li>Look for the mic button among keyboard keys</li>
<li>Tap the mic</li>
<li>Speak</li>
</ol>
</li>
<li>
<p><strong>In-app mic</strong>: Simplified flow:</p>
<ol>
<li>Tap the mic icon (no keyboard needed!)</li>
<li>Speak</li>
</ol>
</li>
</ul>
<p><strong>Result:</strong> <strong>2 fewer steps</strong> and <strong>no keyboard lag</strong>.</p>
<h4 id="4-visual-prominence"><strong>4. Visual Prominence</strong></h4>
<table>
  <thead>
      <tr>
          <th>Keyboard Mic</th>
          <th>In-App Mic</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>5-6mm size typical</td>
          <td>Can be 24-32dp (12-16mm)</td>
      </tr>
      <tr>
          <td>Gray/neutral color</td>
          <td>App-themed, stands out</td>
      </tr>
      <tr>
          <td>Among 30+ keys</td>
          <td>Isolated, clear purpose</td>
      </tr>
      <tr>
          <td>Same across all apps</td>
          <td>Consistent with your app design</td>
      </tr>
  </tbody>
</table>
<h4 id="5-accessibility-considerations"><strong>5. Accessibility Considerations</strong></h4>
<p>Users with <strong>motor impairments</strong> or <strong>visual limitations</strong> benefit significantly:</p>
<ul>
<li>Larger, easier-to-tap target</li>
<li>Better contrast and visibility</li>
<li>Screen readers can announce it contextually</li>
<li>Doesn&rsquo;t require precise keyboard navigation</li>
</ul>
<h4 id="6-user-psychology"><strong>6. User Psychology</strong></h4>
<p><strong>Explicit invitation &gt; Hidden capability</strong></p>
<p>When users see a mic icon next to a text field, it:</p>
<ul>
<li><strong>Signals</strong> that voice input is encouraged</li>
<li><strong>Reduces friction</strong> - they don&rsquo;t need to hunt for it</li>
<li><strong>Increases adoption</strong> - visible features get used more</li>
<li><strong>Feels intentional</strong> - the app <em>wants</em> them to use voice</li>
</ul>
<p>The keyboard mic feels like a <strong>generic fallback</strong>. The in-app mic feels like a <strong>first-class feature</strong>.</p>
<h3 id="real-world-data-points"><strong>Real-World Data Points</strong></h3>
<p>While specific metrics vary by app, general patterns show:</p>
<ul>
<li>📈 <strong>5-10x higher voice input usage</strong> with prominent in-app mic icons</li>
<li>🎯 <strong>New user discovery</strong> - many users don&rsquo;t realize keyboard voice exists</li>
<li>♿ <strong>Accessibility gains</strong> - significant usage increase among users with disabilities</li>
<li>📱 <strong>Mobile-first users</strong> especially benefit (small screen, fat fingers)</li>
</ul>
<h3 id="the-hybrid-approach-best-of-both-worlds"><strong>The Hybrid Approach: Best of Both Worlds</strong></h3>
<p>The ideal solution is <strong>not either/or</strong>, but <strong>both</strong>:</p>
<p>✅ <strong>In-app mic</strong> for discoverability and context
✅ <strong>Keyboard mic</strong> still works as a fallback</p>
<p>Users get:</p>
<ul>
<li>A prominent, obvious voice input option</li>
<li>Fallback if they prefer keyboard mic</li>
<li>Contextual prompts and better UX</li>
<li>No downsides!</li>
</ul>
<h3 id="when-just-use-the-keyboard-fails"><strong>When &ldquo;Just Use the Keyboard&rdquo; Fails</strong></h3>
<p>Some scenarios where keyboard voice input is insufficient:</p>
<ol>
<li><strong>Custom keyboards</strong> - Not all keyboards have voice input</li>
<li><strong>Enterprise devices</strong> - Some organizations disable keyboard voice for security</li>
<li><strong>Locked-down keyboards</strong> - Educational or restricted environments</li>
<li><strong>Non-Google keyboards</strong> - Third-party keyboards may lack voice features</li>
<li><strong>Disabled by user</strong> - Some users disable keyboard permissions</li>
</ol>
<p>Your <strong>in-app implementation</strong> works regardless of keyboard choice.</p>
<hr>
<h2 id="the-bottom-line">The Bottom Line</h2>
<p><strong>&ldquo;Users have voice on their keyboard&rdquo;</strong> is like saying:</p>
<ul>
<li>&ldquo;Don&rsquo;t add a search icon, users can use Ctrl+F&rdquo;</li>
<li>&ldquo;Don&rsquo;t add a share button, users can copy-paste&rdquo;</li>
<li>&ldquo;Don&rsquo;t add undo, users can manually fix mistakes&rdquo;</li>
</ul>
<p><strong>Just because a capability exists somewhere doesn&rsquo;t mean it&rsquo;s discoverable or convenient.</strong></p>
<p>In-app voice input is about <strong>removing friction</strong> and <strong>guiding users</strong> toward better UX. The fact that keyboard voice exists is great - your in-app implementation makes it <strong>more likely to actually be used</strong>.</p>
<hr>
<p>Ever wanted to add voice input to your Android app with minimal effort? <strong>Speech-to-Text</strong> functionality can dramatically improve user experience, especially for note-taking, messaging, or search features.</p>
<p>In this guide, we&rsquo;ll build a <strong>clean, reusable Speech-to-Text component</strong> using <strong>Jetpack Compose</strong> that wraps Android&rsquo;s native speech recognition API.</p>
<h2 id="-what-were-building">🎯 What We&rsquo;re Building</h2>
<p>A composable speech recognition system with:</p>
<ul>
<li>✅ <strong>Simple API</strong> - One composable function to handle everything</li>
<li>✅ <strong>Lifecycle-aware</strong> - Properly managed with Activity Result API</li>
<li>✅ <strong>Locale support</strong> - Respects app language settings</li>
<li>✅ <strong>Availability checking</strong> - Gracefully handles devices without speech recognition</li>
<li>✅ <strong>Reusable state</strong> - Clean separation of concerns</li>
</ul>
<hr>
<h2 id="-architecture-overview">🏗️ Architecture Overview</h2>
<p>Our implementation consists of three main components:</p>
<ol>
<li><strong><code>SystemSpeechToTextHelper</code></strong> - A utility object that handles Android&rsquo;s RecognizerIntent</li>
<li><strong><code>SpeechToTextState</code></strong> - A state holder that manages the speech recognition launcher</li>
<li><strong><code>rememberSpeechToText()</code></strong> - A composable function that creates and remembers the state</li>
<li><strong><code>SpeechToTextButton</code></strong> (Bonus) - A ready-to-use UI component</li>
</ol>
<hr>
<h2 id="-implementation">📝 Implementation</h2>
<h3 id="1-the-helper-object"><strong>1️⃣ The Helper Object</strong></h3>
<p>First, let&rsquo;s create a helper object to encapsulate all Android-specific speech recognition logic:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">object</span> <span style="color:#a6e22e">SystemSpeechToTextHelper</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">getAppLocale</span>(): Locale {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Locale</span>.forLanguageTag(<span style="color:#a6e22e">Language</span>.currentLocale.<span style="color:#66d9ef">value</span>.code)
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">catch</span> (e: Exception) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Locale</span>.getDefault()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">createRecognitionIntent</span>(
</span></span><span style="display:flex;"><span>        languageModel: String = <span style="color:#a6e22e">RecognizerIntent</span>.LANGUAGE_MODEL_FREE_FORM,
</span></span><span style="display:flex;"><span>        locale: Locale = getAppLocale(),
</span></span><span style="display:flex;"><span>        prompt: String? = <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>        maxResults: Int = <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    ): Intent {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> Intent(<span style="color:#a6e22e">RecognizerIntent</span>.ACTION_RECOGNIZE_SPEECH).apply {
</span></span><span style="display:flex;"><span>            putExtra(<span style="color:#a6e22e">RecognizerIntent</span>.EXTRA_LANGUAGE_MODEL, languageModel)
</span></span><span style="display:flex;"><span>            putExtra(<span style="color:#a6e22e">RecognizerIntent</span>.EXTRA_LANGUAGE, locale.toLanguageTag())
</span></span><span style="display:flex;"><span>            putExtra(<span style="color:#a6e22e">RecognizerIntent</span>.EXTRA_MAX_RESULTS, maxResults)
</span></span><span style="display:flex;"><span>            prompt<span style="color:#f92672">?.</span>let { putExtra(<span style="color:#a6e22e">RecognizerIntent</span>.EXTRA_PROMPT, <span style="color:#66d9ef">it</span>) }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">extractSpokenText</span>(result: ActivityResult): String? {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">if</span> (result.resultCode <span style="color:#f92672">==</span> <span style="color:#a6e22e">Activity</span>.RESULT_OK) {
</span></span><span style="display:flex;"><span>            result.<span style="color:#66d9ef">data</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">?.</span>getStringArrayListExtra(<span style="color:#a6e22e">RecognizerIntent</span>.EXTRA_RESULTS)
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">?.</span>firstOrNull()
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">?.</span>takeIf { <span style="color:#66d9ef">it</span>.isNotBlank() }
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">isRecognitionAvailable</span>(context: Context): Boolean {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">val</span> pm = context.packageManager
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">val</span> activities = pm.queryIntentActivities(
</span></span><span style="display:flex;"><span>            Intent(<span style="color:#a6e22e">RecognizerIntent</span>.ACTION_RECOGNIZE_SPEECH),
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">PackageManager</span>.MATCH_DEFAULT_ONLY
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> activities.isNotEmpty()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Key Features:</strong></p>
<ul>
<li>🌍 <strong>Locale handling</strong> - Automatically uses your app&rsquo;s current language</li>
<li>🎤 <strong>Flexible configuration</strong> - Customize prompt, language model, and result count</li>
<li>✅ <strong>Validation</strong> - Ensures speech recognition is available on the device</li>
<li>🧹 <strong>Clean extraction</strong> - Filters out blank results</li>
</ul>
<hr>
<h3 id="2-the-state-holder"><strong>2️⃣ The State Holder</strong></h3>
<p>Next, we create a state class that manages the speech recognition lifecycle:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Stable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SpeechToTextState</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">val</span> launcher: ManagedActivityResultLauncher&lt;Intent, ActivityResult&gt;,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">val</span> prompt: String?,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> isAvailable: Boolean
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">launch</span>(
</span></span><span style="display:flex;"><span>        customPrompt: String? = prompt,
</span></span><span style="display:flex;"><span>        customLocale: Locale? = <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>    ) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">val</span> intent = <span style="color:#a6e22e">SystemSpeechToTextHelper</span>.createRecognitionIntent(
</span></span><span style="display:flex;"><span>            prompt = customPrompt,
</span></span><span style="display:flex;"><span>            locale = customLocale <span style="color:#f92672">?:</span> <span style="color:#a6e22e">SystemSpeechToTextHelper</span>.getAppLocale()
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        launcher.launch(intent)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Why <code>@Stable</code>?</strong>
The <code>@Stable</code> annotation tells Compose that this class follows specific stability contracts, allowing for better recomposition optimizations.</p>
<hr>
<h3 id="3-the-composable-function"><strong>3️⃣ The Composable Function</strong></h3>
<p>Now comes the magic - a composable that ties everything together:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">rememberSpeechToText</span>(
</span></span><span style="display:flex;"><span>    prompt: String? = <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>    onResult: (String) <span style="color:#f92672">-&gt;</span> Unit
</span></span><span style="display:flex;"><span>): SpeechToTextState {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> context = <span style="color:#a6e22e">LocalContext</span>.current
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> launcher = rememberLauncherForActivityResult(
</span></span><span style="display:flex;"><span>        contract = <span style="color:#a6e22e">ActivityResultContracts</span>.StartActivityForResult()
</span></span><span style="display:flex;"><span>    ) { result <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">SystemSpeechToTextHelper</span>.extractSpokenText(result)<span style="color:#f92672">?.</span>let { spokenText <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>            onResult(spokenText)
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> isAvailable = remember {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">SystemSpeechToTextHelper</span>.isRecognitionAvailable(context)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> remember(launcher, prompt, isAvailable) {
</span></span><span style="display:flex;"><span>        SpeechToTextState(
</span></span><span style="display:flex;"><span>            launcher = launcher,
</span></span><span style="display:flex;"><span>            prompt = prompt,
</span></span><span style="display:flex;"><span>            isAvailable = isAvailable
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Key Points:</strong></p>
<ul>
<li>🔄 <strong>Activity Result API</strong> - Modern way to handle activity results</li>
<li>💾 <strong>Remembered state</strong> - Survives recompositions</li>
<li>🎯 <strong>Callback pattern</strong> - Clean result handling via lambda</li>
</ul>
<hr>
<h3 id="4-bonus-ready-to-use-button-component"><strong>4️⃣ Bonus: Ready-to-Use Button Component</strong></h3>
<p>For convenience, here&rsquo;s a pre-built button component:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">SpeechToTextButton</span>(
</span></span><span style="display:flex;"><span>    speechToTextState: SpeechToTextState,
</span></span><span style="display:flex;"><span>    modifier: Modifier = Modifier,
</span></span><span style="display:flex;"><span>    enabled: Boolean = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    iconSize: Dp = <span style="color:#ae81ff">24.</span>dp,
</span></span><span style="display:flex;"><span>    tint: Color = <span style="color:#a6e22e">Color</span>.Unspecified,
</span></span><span style="display:flex;"><span>    contentDescription: String? = <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>    IconButton(
</span></span><span style="display:flex;"><span>        onClick = speechToTextState<span style="color:#f92672">::</span>launch,
</span></span><span style="display:flex;"><span>        enabled = enabled <span style="color:#f92672">&amp;&amp;</span> speechToTextState.isAvailable,
</span></span><span style="display:flex;"><span>        modifier = modifier
</span></span><span style="display:flex;"><span>    ) {
</span></span><span style="display:flex;"><span>        Icon(
</span></span><span style="display:flex;"><span>            painter = painterResource(id = <span style="color:#a6e22e">R</span>.drawable.ic_mic),
</span></span><span style="display:flex;"><span>            contentDescription = contentDescription,
</span></span><span style="display:flex;"><span>            tint = tint,
</span></span><span style="display:flex;"><span>            modifier = <span style="color:#a6e22e">Modifier</span>.size(iconSize)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="-real-world-implementation-examples">🚀 Real-World Implementation Examples</h2>
<h3 id="example-1-textfield-with-voice-input-production-ready"><strong>Example 1: TextField with Voice Input (Production-Ready)</strong></h3>
<p>Here&rsquo;s how to properly integrate voice input with a text field, including validation and network checking:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">SmartTextField</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">value</span>: String,
</span></span><span style="display:flex;"><span>    onValueChange: (String) <span style="color:#f92672">-&gt;</span> Unit,
</span></span><span style="display:flex;"><span>    modifier: Modifier = Modifier,
</span></span><span style="display:flex;"><span>    label: String = <span style="color:#e6db74">&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>    placeholder: String = <span style="color:#e6db74">&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>    isError: Boolean = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    errorMessage: String? = <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>    maxLength: Int? = <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>    singleLine: Boolean = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> context = <span style="color:#a6e22e">LocalContext</span>.current
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> showNetworkWarning <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#66d9ef">false</span>) }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Check network connectivity
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> isNetworkAvailable = remember {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">val</span> cm = context.getSystemService(<span style="color:#a6e22e">Context</span>.CONNECTIVITY_SERVICE) <span style="color:#66d9ef">as</span> ConnectivityManager
</span></span><span style="display:flex;"><span>        cm.activeNetwork <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> speechToText = rememberSpeechToText(
</span></span><span style="display:flex;"><span>        prompt = <span style="color:#e6db74">&#34;Speak </span><span style="color:#e6db74">$label</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    ) { spokenText <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Handle max length validation
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        <span style="color:#66d9ef">val</span> newText = <span style="color:#66d9ef">if</span> (maxLength <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span>) {
</span></span><span style="display:flex;"><span>            spokenText.take(maxLength)
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>            spokenText
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        onValueChange(newText)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Column(modifier = modifier) {
</span></span><span style="display:flex;"><span>        OutlinedTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = <span style="color:#66d9ef">value</span>,
</span></span><span style="display:flex;"><span>            onValueChange = { newValue <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Enforce max length on manual input too
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>                <span style="color:#66d9ef">val</span> sanitized = <span style="color:#66d9ef">if</span> (maxLength <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span>) {
</span></span><span style="display:flex;"><span>                    newValue.take(maxLength)
</span></span><span style="display:flex;"><span>                } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>                    newValue
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>                onValueChange(sanitized)
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            label = { Text(label) },
</span></span><span style="display:flex;"><span>            placeholder = { Text(placeholder) },
</span></span><span style="display:flex;"><span>            isError = isError,
</span></span><span style="display:flex;"><span>            singleLine = singleLine,
</span></span><span style="display:flex;"><span>            modifier = <span style="color:#a6e22e">Modifier</span>.fillMaxWidth(),
</span></span><span style="display:flex;"><span>            trailingIcon = {
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Only show mic icon if speech recognition is available
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>                <span style="color:#66d9ef">if</span> (speechToText.isAvailable) {
</span></span><span style="display:flex;"><span>                    IconButton(
</span></span><span style="display:flex;"><span>                        onClick = {
</span></span><span style="display:flex;"><span>                            <span style="color:#66d9ef">if</span> (isNetworkAvailable) {
</span></span><span style="display:flex;"><span>                                speechToText.launch()
</span></span><span style="display:flex;"><span>                            } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>                                showNetworkWarning = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>                            }
</span></span><span style="display:flex;"><span>                        }
</span></span><span style="display:flex;"><span>                    ) {
</span></span><span style="display:flex;"><span>                        Icon(
</span></span><span style="display:flex;"><span>                            painter = painterResource(id = <span style="color:#a6e22e">R</span>.drawable.ic_mic),
</span></span><span style="display:flex;"><span>                            contentDescription = <span style="color:#e6db74">&#34;Voice input for </span><span style="color:#e6db74">$label</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>                            tint = <span style="color:#66d9ef">if</span> (isNetworkAvailable) {
</span></span><span style="display:flex;"><span>                                <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.primary
</span></span><span style="display:flex;"><span>                            } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>                                <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.onSurface.copy(alpha = <span style="color:#ae81ff">0.38f</span>)
</span></span><span style="display:flex;"><span>                            }
</span></span><span style="display:flex;"><span>                        )
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            supportingText = {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">when</span> {
</span></span><span style="display:flex;"><span>                    errorMessage <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">&amp;&amp;</span> isError <span style="color:#f92672">-&gt;</span> {
</span></span><span style="display:flex;"><span>                        Text(
</span></span><span style="display:flex;"><span>                            text = errorMessage,
</span></span><span style="display:flex;"><span>                            color = <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.error
</span></span><span style="display:flex;"><span>                        )
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                    maxLength <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">-&gt;</span> {
</span></span><span style="display:flex;"><span>                        Text(
</span></span><span style="display:flex;"><span>                            text = <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${value.length}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">$maxLength</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>                            modifier = <span style="color:#a6e22e">Modifier</span>.fillMaxWidth(),
</span></span><span style="display:flex;"><span>                            textAlign = <span style="color:#a6e22e">TextAlign</span>.End
</span></span><span style="display:flex;"><span>                        )
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Network warning
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        <span style="color:#66d9ef">if</span> (showNetworkWarning) {
</span></span><span style="display:flex;"><span>            Text(
</span></span><span style="display:flex;"><span>                text = <span style="color:#e6db74">&#34;Voice input requires internet connection&#34;</span>,
</span></span><span style="display:flex;"><span>                color = <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.error,
</span></span><span style="display:flex;"><span>                style = <span style="color:#a6e22e">MaterialTheme</span>.typography.bodySmall,
</span></span><span style="display:flex;"><span>                modifier = <span style="color:#a6e22e">Modifier</span>.padding(start = <span style="color:#ae81ff">16.</span>dp, top = <span style="color:#ae81ff">4.</span>dp)
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>            LaunchedEffect(Unit) {
</span></span><span style="display:flex;"><span>                delay(<span style="color:#ae81ff">3000</span>)
</span></span><span style="display:flex;"><span>                showNetworkWarning = <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Usage:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">FeedbackForm</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> userName <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> feedback <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> maxFeedbackLength = <span style="color:#ae81ff">500</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Column(modifier = <span style="color:#a6e22e">Modifier</span>.padding(<span style="color:#ae81ff">16.</span>dp)) {
</span></span><span style="display:flex;"><span>        SmartTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = userName,
</span></span><span style="display:flex;"><span>            onValueChange = { userName = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            label = <span style="color:#e6db74">&#34;Your Name&#34;</span>,
</span></span><span style="display:flex;"><span>            placeholder = <span style="color:#e6db74">&#34;John Doe&#34;</span>,
</span></span><span style="display:flex;"><span>            maxLength = <span style="color:#ae81ff">50</span>,
</span></span><span style="display:flex;"><span>            singleLine = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Spacer(modifier = <span style="color:#a6e22e">Modifier</span>.height(<span style="color:#ae81ff">16.</span>dp))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        SmartTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = feedback,
</span></span><span style="display:flex;"><span>            onValueChange = { feedback = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            label = <span style="color:#e6db74">&#34;Feedback&#34;</span>,
</span></span><span style="display:flex;"><span>            placeholder = <span style="color:#e6db74">&#34;Tell us what you think...&#34;</span>,
</span></span><span style="display:flex;"><span>            maxLength = maxFeedbackLength,
</span></span><span style="display:flex;"><span>            singleLine = <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h3 id="example-2-search-bar-with-voice-input"><strong>Example 2: Search Bar with Voice Input</strong></h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">VoiceEnabledSearchBar</span>(
</span></span><span style="display:flex;"><span>    query: String,
</span></span><span style="display:flex;"><span>    onQueryChange: (String) <span style="color:#f92672">-&gt;</span> Unit,
</span></span><span style="display:flex;"><span>    onSearch: () <span style="color:#f92672">-&gt;</span> Unit,
</span></span><span style="display:flex;"><span>    modifier: Modifier = Modifier
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> speechToText = rememberSpeechToText(
</span></span><span style="display:flex;"><span>        prompt = <span style="color:#e6db74">&#34;What are you looking for?&#34;</span>
</span></span><span style="display:flex;"><span>    ) { spokenText <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>        onQueryChange(spokenText)
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Auto-search after voice input
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        onSearch()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    SearchBar(
</span></span><span style="display:flex;"><span>        query = query,
</span></span><span style="display:flex;"><span>        onQueryChange = onQueryChange,
</span></span><span style="display:flex;"><span>        onSearch = { onSearch() },
</span></span><span style="display:flex;"><span>        active = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>        onActiveChange = {},
</span></span><span style="display:flex;"><span>        modifier = modifier,
</span></span><span style="display:flex;"><span>        leadingIcon = {
</span></span><span style="display:flex;"><span>            Icon(
</span></span><span style="display:flex;"><span>                imageVector = <span style="color:#a6e22e">Icons</span>.<span style="color:#a6e22e">Default</span>.Search,
</span></span><span style="display:flex;"><span>                contentDescription = <span style="color:#e6db74">&#34;Search&#34;</span>
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        trailingIcon = {
</span></span><span style="display:flex;"><span>            Row {
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Clear button
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>                <span style="color:#66d9ef">if</span> (query.isNotEmpty()) {
</span></span><span style="display:flex;"><span>                    IconButton(onClick = { onQueryChange(<span style="color:#e6db74">&#34;&#34;</span>) }) {
</span></span><span style="display:flex;"><span>                        Icon(
</span></span><span style="display:flex;"><span>                            imageVector = <span style="color:#a6e22e">Icons</span>.<span style="color:#a6e22e">Default</span>.Close,
</span></span><span style="display:flex;"><span>                            contentDescription = <span style="color:#e6db74">&#34;Clear&#34;</span>
</span></span><span style="display:flex;"><span>                        )
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Voice input button (only if available)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>                <span style="color:#66d9ef">if</span> (speechToText.isAvailable) {
</span></span><span style="display:flex;"><span>                    IconButton(onClick = { speechToText.launch() }) {
</span></span><span style="display:flex;"><span>                        Icon(
</span></span><span style="display:flex;"><span>                            painter = painterResource(id = <span style="color:#a6e22e">R</span>.drawable.ic_mic),
</span></span><span style="display:flex;"><span>                            contentDescription = <span style="color:#e6db74">&#34;Voice search&#34;</span>
</span></span><span style="display:flex;"><span>                        )
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        placeholder = { Text(<span style="color:#e6db74">&#34;Search products...&#34;</span>) }
</span></span><span style="display:flex;"><span>    ) {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Search suggestions
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h3 id="example-3-multi-line-text-input-with-append-mode"><strong>Example 3: Multi-line Text Input with Append Mode</strong></h3>
<p>Perfect for note-taking or messaging apps:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">VoiceNoteEditor</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> noteContent <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> isRecording <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#66d9ef">false</span>) }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> speechToText = rememberSpeechToText(
</span></span><span style="display:flex;"><span>        prompt = <span style="color:#e6db74">&#34;Speak your note&#34;</span>
</span></span><span style="display:flex;"><span>    ) { spokenText <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Intelligently append or replace
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        noteContent = <span style="color:#66d9ef">when</span> {
</span></span><span style="display:flex;"><span>            noteContent.isEmpty() <span style="color:#f92672">-&gt;</span> spokenText
</span></span><span style="display:flex;"><span>            noteContent.endsWith(<span style="color:#e6db74">&#34;.&#34;</span>) <span style="color:#f92672">||</span> noteContent.endsWith(<span style="color:#e6db74">&#34;!&#34;</span>) <span style="color:#f92672">||</span> noteContent.endsWith(<span style="color:#e6db74">&#34;?&#34;</span>) <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">$noteContent</span><span style="color:#e6db74"> </span><span style="color:#e6db74">$spokenText</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span> <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">$noteContent</span><span style="color:#e6db74">. </span><span style="color:#e6db74">$spokenText</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        isRecording = <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Column(
</span></span><span style="display:flex;"><span>        modifier = Modifier
</span></span><span style="display:flex;"><span>            .fillMaxSize()
</span></span><span style="display:flex;"><span>            .padding(<span style="color:#ae81ff">16.</span>dp)
</span></span><span style="display:flex;"><span>    ) {
</span></span><span style="display:flex;"><span>        OutlinedTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = noteContent,
</span></span><span style="display:flex;"><span>            onValueChange = { noteContent = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            modifier = Modifier
</span></span><span style="display:flex;"><span>                .fillMaxWidth()
</span></span><span style="display:flex;"><span>                .weight(<span style="color:#ae81ff">1f</span>),
</span></span><span style="display:flex;"><span>            placeholder = {
</span></span><span style="display:flex;"><span>                Text(<span style="color:#e6db74">&#34;Start typing or tap the mic to speak...&#34;</span>)
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            textStyle = <span style="color:#a6e22e">MaterialTheme</span>.typography.bodyLarge
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Spacer(modifier = <span style="color:#a6e22e">Modifier</span>.height(<span style="color:#ae81ff">16.</span>dp))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Row(
</span></span><span style="display:flex;"><span>            modifier = <span style="color:#a6e22e">Modifier</span>.fillMaxWidth(),
</span></span><span style="display:flex;"><span>            horizontalArrangement = <span style="color:#a6e22e">Arrangement</span>.SpaceBetween,
</span></span><span style="display:flex;"><span>            verticalAlignment = <span style="color:#a6e22e">Alignment</span>.CenterVertically
</span></span><span style="display:flex;"><span>        ) {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Word count
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            Text(
</span></span><span style="display:flex;"><span>                text = <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${noteContent.split(&#34;\\s+&#34;.toRegex()).size}</span><span style="color:#e6db74"> words&#34;</span>,
</span></span><span style="display:flex;"><span>                style = <span style="color:#a6e22e">MaterialTheme</span>.typography.bodySmall,
</span></span><span style="display:flex;"><span>                color = <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.onSurfaceVariant
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Voice input button
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#66d9ef">if</span> (speechToText.isAvailable) {
</span></span><span style="display:flex;"><span>                FilledTonalButton(
</span></span><span style="display:flex;"><span>                    onClick = {
</span></span><span style="display:flex;"><span>                        isRecording = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>                        speechToText.launch()
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                ) {
</span></span><span style="display:flex;"><span>                    Icon(
</span></span><span style="display:flex;"><span>                        painter = painterResource(id = <span style="color:#a6e22e">R</span>.drawable.ic_mic),
</span></span><span style="display:flex;"><span>                        contentDescription = <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>                        modifier = <span style="color:#a6e22e">Modifier</span>.size(<span style="color:#ae81ff">20.</span>dp)
</span></span><span style="display:flex;"><span>                    )
</span></span><span style="display:flex;"><span>                    Spacer(modifier = <span style="color:#a6e22e">Modifier</span>.width(<span style="color:#ae81ff">8.</span>dp))
</span></span><span style="display:flex;"><span>                    Text(<span style="color:#66d9ef">if</span> (isRecording) <span style="color:#e6db74">&#34;Listening...&#34;</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;Add Voice Note&#34;</span>)
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h3 id="example-4-form-with-conditional-voice-input"><strong>Example 4: Form with Conditional Voice Input</strong></h3>
<p>Shows how to conditionally enable voice input based on field type:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">UserRegistrationForm</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> name <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> email <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> bio <span style="color:#66d9ef">by</span> remember { mutableStateOf(<span style="color:#e6db74">&#34;&#34;</span>) }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Column(modifier = <span style="color:#a6e22e">Modifier</span>.padding(<span style="color:#ae81ff">16.</span>dp)) {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Name field - voice input enabled
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        SmartTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = name,
</span></span><span style="display:flex;"><span>            onValueChange = { name = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            label = <span style="color:#e6db74">&#34;Full Name&#34;</span>,
</span></span><span style="display:flex;"><span>            maxLength = <span style="color:#ae81ff">50</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Spacer(modifier = <span style="color:#a6e22e">Modifier</span>.height(<span style="color:#ae81ff">16.</span>dp))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Email field - voice input disabled (too error-prone)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        OutlinedTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = email,
</span></span><span style="display:flex;"><span>            onValueChange = { email = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            label = { Text(<span style="color:#e6db74">&#34;Email&#34;</span>) },
</span></span><span style="display:flex;"><span>            keyboardOptions = KeyboardOptions(
</span></span><span style="display:flex;"><span>                keyboardType = <span style="color:#a6e22e">KeyboardType</span>.Email
</span></span><span style="display:flex;"><span>            ),
</span></span><span style="display:flex;"><span>            modifier = <span style="color:#a6e22e">Modifier</span>.fillMaxWidth()
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// No voice input for email - typing is more accurate
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Spacer(modifier = <span style="color:#a6e22e">Modifier</span>.height(<span style="color:#ae81ff">16.</span>dp))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Bio field - voice input enabled
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        SmartTextField(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">value</span> = bio,
</span></span><span style="display:flex;"><span>            onValueChange = { bio = <span style="color:#66d9ef">it</span> },
</span></span><span style="display:flex;"><span>            label = <span style="color:#e6db74">&#34;Bio&#34;</span>,
</span></span><span style="display:flex;"><span>            placeholder = <span style="color:#e6db74">&#34;Tell us about yourself...&#34;</span>,
</span></span><span style="display:flex;"><span>            maxLength = <span style="color:#ae81ff">200</span>,
</span></span><span style="display:flex;"><span>            singleLine = <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="-critical-implementation-guidelines">⚠️ Critical Implementation Guidelines</h2>
<h3 id="1-always-check-availability"><strong>1. Always Check Availability</strong></h3>
<p><strong>Never show the mic icon if speech recognition is unavailable.</strong> This creates a poor UX when users tap it and nothing happens.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#75715e">// ✅ GOOD - Only show when available
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">if</span> (speechToText.isAvailable) {
</span></span><span style="display:flex;"><span>    SpeechToTextButton(speechToTextState = speechToText)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ❌ BAD - Shows disabled button (confusing UX)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>SpeechToTextButton(
</span></span><span style="display:flex;"><span>    speechToTextState = speechToText,
</span></span><span style="display:flex;"><span>    enabled = speechToText.isAvailable  <span style="color:#75715e">// Don&#39;t do this!
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>)
</span></span></code></pre></div><p><strong>Why?</strong> On devices without Google services (some custom ROMs, enterprise devices), the feature won&rsquo;t work. Hiding it entirely is cleaner than showing a permanently disabled button.</p>
<hr>
<h3 id="2-handle-network-connectivity"><strong>2. Handle Network Connectivity</strong></h3>
<p>Speech recognition requires <strong>active internet connection</strong>. Check before launching:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">isNetworkAvailable</span>(context: Context): Boolean {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> cm = context.getSystemService(<span style="color:#a6e22e">Context</span>.CONNECTIVITY_SERVICE) <span style="color:#66d9ef">as</span> ConnectivityManager
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> cm.activeNetwork <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Usage
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">val</span> isNetworkAvailable = remember {
</span></span><span style="display:flex;"><span>    isNetworkAvailable(context)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>IconButton(
</span></span><span style="display:flex;"><span>    onClick = {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (isNetworkAvailable) {
</span></span><span style="display:flex;"><span>            speechToText.launch()
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Show snackbar or toast
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#a6e22e">Toast</span>.makeText(context, <span style="color:#e6db74">&#34;Voice input requires internet&#34;</span>, <span style="color:#a6e22e">Toast</span>.LENGTH_SHORT).show()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>    Icon(
</span></span><span style="display:flex;"><span>        painter = painterResource(id = <span style="color:#a6e22e">R</span>.drawable.ic_mic),
</span></span><span style="display:flex;"><span>        tint = <span style="color:#66d9ef">if</span> (isNetworkAvailable) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.primary
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">MaterialTheme</span>.colorScheme.onSurface.copy(alpha = <span style="color:#ae81ff">0.38f</span>)
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Best Practice:</strong> Show the mic icon in a dimmed state when offline, and display a brief message when tapped.</p>
<hr>
<h3 id="3-input-validation-after-voice-input"><strong>3. Input Validation After Voice Input</strong></h3>
<p>Always validate voice input just like you would keyboard input:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">val</span> speechToText = rememberSpeechToText { spokenText <span style="color:#f92672">-&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Sanitize and validate
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> sanitized = spokenText
</span></span><span style="display:flex;"><span>        .trim()
</span></span><span style="display:flex;"><span>        .take(maxLength)
</span></span><span style="display:flex;"><span>        .filter { <span style="color:#66d9ef">it</span>.isLetterOrDigit() <span style="color:#f92672">||</span> <span style="color:#66d9ef">it</span>.isWhitespace() }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Check if valid
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (sanitized.isNotEmpty()) {
</span></span><span style="display:flex;"><span>        inputValue = sanitized
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>        showError(<span style="color:#e6db74">&#34;Invalid input received&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Common validations:</p>
<ul>
<li><strong>Length limits</strong> - Trim to max length</li>
<li><strong>Character filtering</strong> - Remove special chars if needed</li>
<li><strong>Empty checks</strong> - Handle blank results</li>
<li><strong>Format validation</strong> - Email, phone, etc.</li>
</ul>
<hr>
<h3 id="4-permissions---none-required"><strong>4. Permissions - None Required!</strong></h3>
<p><strong>Good news:</strong> No runtime permissions needed! Android&rsquo;s speech recognition uses Google&rsquo;s cloud service, which handles all the heavy lifting.</p>
<p>This is a <strong>huge advantage</strong> over custom speech recognition libraries that require <code>RECORD_AUDIO</code> permission.</p>
<hr>
<h3 id="5-locale-and-language-support"><strong>5. Locale and Language Support</strong></h3>
<p>By default, the implementation respects your app&rsquo;s current locale:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">getAppLocale</span>(): Locale {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">Locale</span>.forLanguageTag(<span style="color:#a6e22e">Language</span>.currentLocale.<span style="color:#66d9ef">value</span>.code)
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> (e: Exception) {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">Locale</span>.getDefault()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>For multilingual apps</strong>, you can override the locale per-field:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#75715e">// Spanish input for a specific field
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>speechToText.launch(customLocale = Locale(<span style="color:#e6db74">&#34;es&#34;</span>, <span style="color:#e6db74">&#34;ES&#34;</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// French input
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>speechToText.launch(customLocale = <span style="color:#a6e22e">Locale</span>.FRANCE)
</span></span></code></pre></div><hr>
<h3 id="6-when-not-to-use-voice-input"><strong>6. When NOT to Use Voice Input</strong></h3>
<p>Some fields are better suited for keyboard input:</p>
<p>❌ <strong>Email addresses</strong> - Punctuation and special characters are error-prone
❌ <strong>Passwords</strong> - Security risk + poor accuracy
❌ <strong>Credit card numbers</strong> - High error rate + security concerns
❌ <strong>URLs</strong> - Complex syntax not recognized well
❌ <strong>Code snippets</strong> - Special characters and formatting issues</p>
<p>✅ <strong>Good use cases:</strong>
✔ Names, addresses, descriptions
✔ Search queries
✔ Notes and messages
✔ Feedback and reviews
✔ Long-form text content</p>
<hr>
<h2 id="-ux-impact-the-numbers">📊 UX Impact: The Numbers</h2>
<p>Why voice input matters for your app&rsquo;s user experience:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Typing</th>
          <th>Voice Input</th>
          <th>Improvement</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Average Speed</strong></td>
          <td>40 words/min</td>
          <td>150+ words/min</td>
          <td><strong>3.75x faster</strong></td>
      </tr>
      <tr>
          <td><strong>Error Rate</strong></td>
          <td>2-3%</td>
          <td>5-8% (but faster to correct)</td>
          <td>Context dependent</td>
      </tr>
      <tr>
          <td><strong>User Effort</strong></td>
          <td>High (small keyboards)</td>
          <td>Low (hands-free)</td>
          <td><strong>Significantly lower</strong></td>
      </tr>
      <tr>
          <td><strong>Accessibility</strong></td>
          <td>Difficult for some users</td>
          <td>Easy for most users</td>
          <td><strong>Universal access</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>Real-world impact:</strong></p>
<ul>
<li>📝 A 100-word product review takes <strong>2.5 minutes typing</strong> vs <strong>40 seconds speaking</strong></li>
<li>🔍 Voice search feels instantaneous vs typing lag</li>
<li>♿ Critical for users with motor impairments, RSI, or visual limitations</li>
<li>🌍 Easier for non-native keyboard users</li>
</ul>
<hr>
<h2 id="-why-this-implementation-is-superior">✅ Why This Implementation is Superior</h2>
<h3 id="compared-to-keyboard-input"><strong>Compared to Keyboard Input:</strong></h3>
<p>✔ <strong>3-5x faster input</strong> for long text
✔ <strong>Lower cognitive load</strong> - speaking is more natural than typing
✔ <strong>Better mobile experience</strong> - no tiny keyboard frustration
✔ <strong>Hands-free operation</strong> - can be used while multitasking</p>
<h3 id="compared-to-third-party-libraries"><strong>Compared to Third-Party Libraries:</strong></h3>
<p>✔ <strong>Zero app size increase</strong> - uses system APIs
✔ <strong>No permissions required</strong> - no <code>RECORD_AUDIO</code> prompt
✔ <strong>Always up-to-date</strong> - Google maintains the recognition engine
✔ <strong>No API keys or quotas</strong> - completely free
✔ <strong>Better privacy</strong> - uses Google&rsquo;s standard speech service (same as Gboard)</p>
<h3 id="compared-to-custom-ml-models"><strong>Compared to Custom ML Models:</strong></h3>
<p>✔ <strong>No model training needed</strong>
✔ <strong>No storage for ML models</strong> (models can be 50MB+)
✔ <strong>Supports 100+ languages</strong> out of the box
✔ <strong>Continuously improving</strong> - benefits from Google&rsquo;s updates</p>
<hr>
<h2 id="-when-voice-input-makes-sense">🎯 When Voice Input Makes Sense</h2>
<h3 id="perfect-use-cases"><strong>Perfect Use Cases:</strong></h3>
<ul>
<li>📝 <strong>Note-taking and memos</strong> - Natural dictation flow</li>
<li>💬 <strong>Messaging and chat</strong> - Quick voice-to-text messages</li>
<li>🔍 <strong>Search queries</strong> - Faster than typing</li>
<li>📋 <strong>Long-form content</strong> - Reviews, feedback, descriptions</li>
<li>♿ <strong>Accessibility features</strong> - Essential for many users</li>
<li>🚗 <strong>Hands-free scenarios</strong> - When typing isn&rsquo;t safe</li>
</ul>
<h3 id="skip-voice-input-for"><strong>Skip Voice Input For:</strong></h3>
<ul>
<li>🔒 <strong>Sensitive data</strong> - Passwords, PINs, SSNs</li>
<li>📧 <strong>Format-specific fields</strong> - Emails, URLs, credit cards</li>
<li>🔢 <strong>Numeric codes</strong> - OTPs, account numbers</li>
<li>💻 <strong>Technical input</strong> - Code, command-line syntax</li>
</ul>
<hr>
<h2 id="-performance-considerations">🚀 Performance Considerations</h2>
<p><strong>App Size Impact:</strong> <strong>0 KB</strong> - Uses system APIs only</p>
<p><strong>Runtime Performance:</strong></p>
<ul>
<li>Minimal memory usage</li>
<li>Lazy initialization (only when needed)</li>
<li>No background processes</li>
<li>Network call only during active recognition</li>
</ul>
<p><strong>Battery Impact:</strong></p>
<ul>
<li>Negligible - recognition happens on Google&rsquo;s servers</li>
<li>No continuous listening (only when user taps mic)</li>
<li>Automatic cleanup after recognition</li>
</ul>
<hr>
<h2 id="-quick-implementation-checklist">🎁 Quick Implementation Checklist</h2>
<p>Before shipping voice input to production, verify:</p>
<ul>
<li><input disabled="" type="checkbox"> ✅ Mic icon only shows when <code>isAvailable == true</code></li>
<li><input disabled="" type="checkbox"> 🌐 Network connectivity is checked before launching</li>
<li><input disabled="" type="checkbox"> ✍️ Input validation applied to voice results</li>
<li><input disabled="" type="checkbox"> 📏 Max length limits enforced</li>
<li><input disabled="" type="checkbox"> 🌍 Proper locale configuration</li>
<li><input disabled="" type="checkbox"> ⚠️ User feedback for network errors</li>
<li><input disabled="" type="checkbox"> 📱 Tested on devices without Google services</li>
<li><input disabled="" type="checkbox"> ♿ Content descriptions added for accessibility</li>
<li><input disabled="" type="checkbox"> 🎨 Visual feedback when mic is active (if custom UI)</li>
</ul>
<hr>
<h2 id="-related-resources">🔗 Related Resources</h2>
<ul>
<li><a href="https://developer.android.com/reference/android/speech/RecognizerIntent">Android Speech Recognition Guide</a></li>
<li><a href="https://developer.android.com/training/basics/intents/result">Jetpack Compose Activity Result API</a></li>
<li><a href="https://m3.material.io/foundations/interaction/input">Material Design Voice Input Guidelines</a></li>
</ul>
<hr>
<h2 id="-final-thoughts">💡 Final Thoughts</h2>
<p>Voice input is a <strong>low-effort, high-impact feature</strong> that most apps overlook. With zero dependencies, no permissions, and minimal code, there&rsquo;s little reason not to add it where appropriate.</p>
<p><strong>The key differentiators:</strong></p>
<ol>
<li><strong>Always check availability</strong> - hide the feature gracefully when unavailable</li>
<li><strong>Validate network state</strong> - provide feedback when offline</li>
<li><strong>Apply proper validation</strong> - treat voice input like any other input</li>
<li><strong>Choose appropriate fields</strong> - not everything needs voice input</li>
</ol>
<p>By following these guidelines, you&rsquo;ll provide a professional, polished experience that sets your app apart.</p>
<hr>
<p><strong>That&rsquo;s it!</strong> You now have a fully functional, production-ready speech-to-text component for Jetpack Compose. 🎉</p>
<p>Feel free to customize this implementation to fit your app&rsquo;s specific needs. If you have questions or suggestions, reach out via my social handles! 😊</p>
<p><strong>Happy coding!</strong> 🚀</p>
]]></content:encoded></item><item><title>Clickable Links in Jetpack Compose: AnnotatedString with Custom URL Handler</title><link>https://md.eknath.dev/posts/android/clickable-links/</link><pubDate>Tue, 11 Nov 2025 22:30:28 +0530</pubDate><guid>https://md.eknath.dev/posts/android/clickable-links/</guid><description>&lt;p>While building the onboarding screen for my app, I needed a simple text that read something like this:&lt;/p>
&lt;p>&lt;img alt="Example of the Implementation" loading="lazy" src="https://md.eknath.dev/img/android/clickable-text-android-example.jpeg">
&lt;em>The green hyperlinks are clickable&lt;/em>&lt;/p>
&lt;p>Pretty straightforward, right? Just a bit of text with two clickable links — &lt;code>Privacy Policy&lt;/code> and &lt;code>Terms of Service&lt;/code>.&lt;/p>
&lt;p>But as I started implementing it, I realized: &lt;strong>there isn&amp;rsquo;t a single complete guide&lt;/strong> explaining how to make part of a text clickable using &lt;code>AnnotatedString&lt;/code> in Jetpack Compose — especially with the new &lt;strong>LinkAnnotation API&lt;/strong> and a &lt;strong>custom URL handler&lt;/strong> for better control.&lt;/p></description><content:encoded><![CDATA[<p>While building the onboarding screen for my app, I needed a simple text that read something like this:</p>
<p><img alt="Example of the Implementation" loading="lazy" src="/img/android/clickable-text-android-example.jpeg">
<em>The green hyperlinks are clickable</em></p>
<p>Pretty straightforward, right? Just a bit of text with two clickable links — <code>Privacy Policy</code> and <code>Terms of Service</code>.</p>
<p>But as I started implementing it, I realized: <strong>there isn&rsquo;t a single complete guide</strong> explaining how to make part of a text clickable using <code>AnnotatedString</code> in Jetpack Compose — especially with the new <strong>LinkAnnotation API</strong> and a <strong>custom URL handler</strong> for better control.</p>
<p>So I&rsquo;m writing this as both a reference for myself and for anyone else struggling with this tiny, under-documented detail.</p>
<hr>
<h2 id="why-custom-url-handling">Why Custom URL Handling?</h2>
<p>By default, Compose can open links using the system browser. But what if you want:</p>
<ul>
<li><strong>Better UX with Chrome Custom Tabs</strong> (opens links in-app with smooth transitions)</li>
<li><strong>Graceful fallbacks</strong> (handle cases where browsers aren&rsquo;t installed)</li>
<li><strong>Full control</strong> over how URLs are opened (analytics, deep links, etc.)</li>
</ul>
<p>That&rsquo;s exactly what we&rsquo;ll implement.</p>
<hr>
<h2 id="step-by-step-implementation">Step-by-Step Implementation</h2>
<h3 id="1-add-the-browser-library">1. Add the Browser Library</h3>
<p>We&rsquo;ll use <strong>Android Browser Library</strong> to implement Chrome Custom Tabs for a better link-opening experience.</p>
<p>Add this to your <code>libs.versions.toml</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-toml" data-lang="toml"><span style="display:flex;"><span>[<span style="color:#a6e22e">versions</span>]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">browser</span> = <span style="color:#e6db74">&#34;1.8.0&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>[<span style="color:#a6e22e">libraries</span>]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">androidx-browser</span> = { <span style="color:#a6e22e">group</span> = <span style="color:#e6db74">&#34;androidx.browser&#34;</span>, <span style="color:#a6e22e">name</span> = <span style="color:#e6db74">&#34;browser&#34;</span>, <span style="color:#a6e22e">version</span>.<span style="color:#a6e22e">ref</span> = <span style="color:#e6db74">&#34;browser&#34;</span> }
</span></span></code></pre></div><p>Then add the dependency to your app&rsquo;s <code>build.gradle.kts</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span>dependencies {
</span></span><span style="display:flex;"><span>    implementation(libs.androidx.browser)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Why Chrome Custom Tabs?</strong>
Custom Tabs open web content <strong>inside your app</strong> with a smooth transition, pre-loaded browser engine, and shared cookies/sessions — all without leaving your app&rsquo;s context.</p>
<hr>
<h3 id="2-create-a-custom-url-handler">2. Create a Custom URL Handler</h3>
<p>This function handles opening URLs with <strong>three layers of fallback</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">urlHandler</span>(url: String, context: Context) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// First: Try Chrome Custom Tabs (best UX)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        <span style="color:#66d9ef">val</span> customTabsIntent = <span style="color:#a6e22e">CustomTabsIntent</span>.Builder().build()
</span></span><span style="display:flex;"><span>        customTabsIntent.launchUrl(context, <span style="color:#a6e22e">Uri</span>.parse(url))
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> (e: ActivityNotFoundException) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Second: Fallback to system browser
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#66d9ef">val</span> intent = Intent(<span style="color:#a6e22e">Intent</span>.ACTION_VIEW, <span style="color:#a6e22e">Uri</span>.parse(url))
</span></span><span style="display:flex;"><span>            context.startActivity(intent)
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">catch</span> (e: Exception) {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Third: Show toast if no browser exists
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#a6e22e">Toast</span>.makeText(
</span></span><span style="display:flex;"><span>                context,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;Browser not installed! visit: </span><span style="color:#e6db74">$url</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">Toast</span>.LENGTH_LONG
</span></span><span style="display:flex;"><span>            ).show()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> (e: Exception) {
</span></span><span style="display:flex;"><span>        e.printStackTrace()
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">Toast</span>.makeText(
</span></span><span style="display:flex;"><span>            context,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;Unable to open link! visit: </span><span style="color:#e6db74">$url</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Toast</span>.LENGTH_SHORT
</span></span><span style="display:flex;"><span>        ).show()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>How it works:</strong></p>
<ol>
<li><strong>Chrome Custom Tabs</strong> (preferred): Opens in-app with native feel</li>
<li><strong>System Browser</strong> (fallback): Opens in the default browser if Custom Tabs fail</li>
<li><strong>Toast Message</strong> (last resort): Shows URL if no browser is available</li>
</ol>
<p>This ensures <strong>your app never crashes</strong> when opening links, even on devices without browsers.</p>
<hr>
<h3 id="3-building-the-clickable-text-component">3. Building the Clickable Text Component</h3>
<p>Now let&rsquo;s create the actual composable with clickable links.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@OptIn</span>(ExperimentalTextApi<span style="color:#f92672">::</span><span style="color:#66d9ef">class</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">TermsAndCondition</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">val</span> context = <span style="color:#a6e22e">LocalContext</span>.current
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Define how links should behave when clicked
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> linkInteractionListener = LinkInteractionListener {
</span></span><span style="display:flex;"><span>        urlHandler((<span style="color:#66d9ef">it</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">LinkAnnotation</span>.Url).url, context)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Define link styling (color + underline)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> linkStyle = TextLinkStyles(
</span></span><span style="display:flex;"><span>        SpanStyle(
</span></span><span style="display:flex;"><span>            color = DGreen,
</span></span><span style="display:flex;"><span>            textDecoration = <span style="color:#a6e22e">TextDecoration</span>.Underline
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Create Privacy Policy link annotation
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> privacyPolicy = <span style="color:#a6e22e">LinkAnnotation</span>.Url(
</span></span><span style="display:flex;"><span>        url = <span style="color:#a6e22e">URLS</span>.PRIVACY,
</span></span><span style="display:flex;"><span>        styles = linkStyle,
</span></span><span style="display:flex;"><span>        linkInteractionListener = linkInteractionListener
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Create Terms of Service link annotation
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> toc = <span style="color:#a6e22e">LinkAnnotation</span>.Url(
</span></span><span style="display:flex;"><span>        url = <span style="color:#a6e22e">URLS</span>.TOC,
</span></span><span style="display:flex;"><span>        styles = linkStyle,
</span></span><span style="display:flex;"><span>        linkInteractionListener = linkInteractionListener
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Helper functions for reusable link text (supports localization!)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">AnnotatedString</span>.<span style="color:#a6e22e">Builder</span>.privacyLink() {
</span></span><span style="display:flex;"><span>        withLink(privacyPolicy) {
</span></span><span style="display:flex;"><span>            append(stringResource(id = <span style="color:#a6e22e">R</span>.string.privacy_policy))
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">AnnotatedString</span>.<span style="color:#a6e22e">Builder</span>.tocLink() {
</span></span><span style="display:flex;"><span>        withLink(toc) {
</span></span><span style="display:flex;"><span>            append(stringResource(id = <span style="color:#a6e22e">R</span>.string.terms_of_service))
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Build the complete annotated string with mixed text + links
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">val</span> annotatedString = buildAnnotatedString {
</span></span><span style="display:flex;"><span>        append(<span style="color:#e6db74">&#34;Read &#34;</span>)
</span></span><span style="display:flex;"><span>        privacyLink()  <span style="color:#75715e">// Clickable Privacy Policy
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>        append(<span style="color:#e6db74">&#34; and tap on `&#34;</span>)
</span></span><span style="display:flex;"><span>        withStyle(style = SpanStyle(fontWeight = <span style="color:#a6e22e">FontWeight</span>.Bold)) {
</span></span><span style="display:flex;"><span>            append(<span style="color:#e6db74">&#34;Agree and Continue&#34;</span>)
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        append(<span style="color:#e6db74">&#34;` to accept the &#34;</span>)
</span></span><span style="display:flex;"><span>        tocLink()  <span style="color:#75715e">// Clickable Terms of Service
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Text(
</span></span><span style="display:flex;"><span>        text = annotatedString,
</span></span><span style="display:flex;"><span>        style = <span style="color:#a6e22e">PannaiTheme</span>.typography.semiBold12.copy(
</span></span><span style="display:flex;"><span>            color = LGray,
</span></span><span style="display:flex;"><span>            textAlign = <span style="color:#a6e22e">TextAlign</span>.Center
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>        modifier = <span style="color:#a6e22e">Modifier</span>.fillMaxWidth(<span style="color:#ae81ff">0.8f</span>),
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="breaking-down-the-code">Breaking Down the Code</h2>
<h3 id="linkinteractionlistener">LinkInteractionListener</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">val</span> linkInteractionListener = LinkInteractionListener {
</span></span><span style="display:flex;"><span>    urlHandler((<span style="color:#66d9ef">it</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">LinkAnnotation</span>.Url).url, context)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ul>
<li>This listener gets triggered when <strong>any link is clicked</strong></li>
<li>It extracts the URL from the <code>LinkAnnotation.Url</code> object</li>
<li>Passes it to our custom <code>urlHandler()</code> for controlled opening</li>
</ul>
<hr>
<h3 id="linkannotationurl">LinkAnnotation.Url</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">val</span> privacyPolicy = <span style="color:#a6e22e">LinkAnnotation</span>.Url(
</span></span><span style="display:flex;"><span>    url = <span style="color:#a6e22e">URLS</span>.PRIVACY,
</span></span><span style="display:flex;"><span>    styles = linkStyle,
</span></span><span style="display:flex;"><span>    linkInteractionListener = linkInteractionListener
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><ul>
<li><strong>url</strong>: The actual link destination</li>
<li><strong>styles</strong>: How the link looks (color, underline, etc.)</li>
<li><strong>linkInteractionListener</strong>: What happens when clicked</li>
</ul>
<p>This is the <strong>new Compose way</strong> of handling links (replaces older <code>pushStringAnnotation</code> approach).</p>
<hr>
<h3 id="helper-functions-for-localization">Helper Functions for Localization</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#a6e22e">@Composable</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fun</span> <span style="color:#a6e22e">AnnotatedString</span>.<span style="color:#a6e22e">Builder</span>.privacyLink() {
</span></span><span style="display:flex;"><span>    withLink(privacyPolicy) {
</span></span><span style="display:flex;"><span>        append(stringResource(id = <span style="color:#a6e22e">R</span>.string.privacy_policy))
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Why helper functions?</strong>
By using <code>stringResource()</code>, the link text <strong>automatically updates</strong> based on user&rsquo;s language. This makes your code cleaner, reusable, and supports multi-language apps without duplicating logic.</p>
<hr>
<h3 id="building-the-final-text">Building the Final Text</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#66d9ef">val</span> annotatedString = buildAnnotatedString {
</span></span><span style="display:flex;"><span>    append(<span style="color:#e6db74">&#34;Read &#34;</span>)
</span></span><span style="display:flex;"><span>    privacyLink()  <span style="color:#75715e">// This part is clickable!
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    append(<span style="color:#e6db74">&#34; and tap on `&#34;</span>)
</span></span><span style="display:flex;"><span>    withStyle(style = SpanStyle(fontWeight = <span style="color:#a6e22e">FontWeight</span>.Bold)) {
</span></span><span style="display:flex;"><span>        append(<span style="color:#e6db74">&#34;Agree and Continue&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    append(<span style="color:#e6db74">&#34;` to accept the &#34;</span>)
</span></span><span style="display:flex;"><span>    tocLink()  <span style="color:#75715e">// This part is also clickable!
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>}
</span></span></code></pre></div><p>This combines <strong>regular text</strong> + <strong>clickable links</strong> + <strong>styled text</strong> in one component. <code>withLink()</code> wraps text and makes it interactive, while <code>withStyle()</code> applies visual formatting without making it clickable.</p>
<hr>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>LinkAnnotation API</strong> is the modern way to create clickable links in Compose</li>
<li><strong>Custom URL handler</strong> gives you full control over how links open</li>
<li><strong>Chrome Custom Tabs</strong> provides better UX than default browser</li>
<li><strong>Graceful fallbacks</strong> prevent app crashes on edge cases</li>
<li><strong>Helper functions + stringResource</strong> make your code localization-ready</li>
</ul>
<hr>
<h2 id="why-this-approach-is-better">Why This Approach is Better</h2>
<p>Compared to older methods using <code>pushStringAnnotation</code>, this approach:</p>
<ul>
<li><strong>Type-safe</strong>: LinkAnnotation is strongly typed</li>
<li><strong>Easier styling</strong>: Direct style application per link</li>
<li><strong>Better separation</strong>: Link logic separate from text building</li>
<li><strong>Localization-friendly</strong>: Works seamlessly with multi-language apps</li>
</ul>
<hr>
<h2 id="related-resources">Related Resources</h2>
<ul>
<li><a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/text/LinkAnnotation">LinkAnitation Documentation</a></li>
<li><a href="https://developer.chrome.com/docs/android/custom-tabs/guide-get-started">Chrome Custom Tabs Guide</a></li>
</ul>
<hr>
<p><strong>Thanks for reading!</strong> If you found this helpful, feel free to reach out via my social handles.</p>
]]></content:encoded></item></channel></rss>