<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: flutter</title>
    <description>The latest articles tagged 'flutter' on DEV Community.</description>
    <link>https://dev.to/t/flutter</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/flutter"/>
    <language>en</language>
    <item>
      <title>Building Enterprise Active Directory, LDAP &amp; Dynamic RBAC in Go &amp; Flutter with Google Antigravity</title>
      <dc:creator>Mario Ezquerro</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:00:16 +0000</pubDate>
      <link>https://dev.to/gde/building-enterprise-active-directory-ldap-dynamic-rbac-in-go-flutter-with-google-antigravity-4al4</link>
      <guid>https://dev.to/gde/building-enterprise-active-directory-ldap-dynamic-rbac-in-go-flutter-with-google-antigravity-4al4</guid>
      <description>&lt;h1&gt;
  
  
  Building Enterprise Active Directory, LDAP &amp;amp; Dynamic RBAC in Go &amp;amp; Flutter with Google Antigravity
&lt;/h1&gt;

&lt;p&gt;When building a lightweight container orchestrator like &lt;strong&gt;&lt;a href="https://github.com/mario-ezquerro/gubernator" rel="noopener noreferrer"&gt;Gubernator (gbnt)&lt;/a&gt;&lt;/strong&gt; — designed to strike the perfect balance between the &lt;strong&gt;simplicity of Docker Swarm&lt;/strong&gt; and the &lt;strong&gt;flexibility of Nomad&lt;/strong&gt; under a Roman Empire theme — a critical milestone inevitably emerges: &lt;strong&gt;Enterprise Security and Access Control&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;While a default &lt;code&gt;admin&lt;/code&gt; credential works well for local dev environments, moving into enterprise production with multi-disciplinary engineering teams demands:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Corporate Single Sign-On (SSO)&lt;/strong&gt; with Microsoft Active Directory and OpenLDAP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role-Based Access Control (RBAC)&lt;/strong&gt; to clearly segregate who can deploy stacks, restart containers, or audit telemetries in read-only mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Group Mapping&lt;/strong&gt; from corporate security groups (&lt;code&gt;memberOf&lt;/code&gt;) to orchestrator roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emergency Break-Glass Access&lt;/strong&gt; (Local Administrator) in case network directory controllers are unreachable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this article, we explore the complete architecture of the enterprise security engine introduced in &lt;strong&gt;Gubernator v2.20.0&lt;/strong&gt;, and how we leveraged &lt;strong&gt;Google Antigravity (AGY)&lt;/strong&gt; as an autonomous AI pair programmer to design, implement, test, and verify this Full-Stack feature (Go + Flutter Web) across a live 3-node cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Security Architecture
&lt;/h2&gt;

&lt;p&gt;We designed a decoupled, asymmetric architecture connecting identity providers, REST API middleware, and the Flutter Web UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌────────────────────────────────────────────────────────┐
 │                   GUBERNATOR WEB UI                    │
 │   - Modern Login Screen with Domain / AD Selector      │
 │   - Header Role Badge: Admin |  Ops |  Read-Only.      │
 └──────────────────────────┬─────────────────────────────┘
                            │ (REST /api/auth/login)
                            ▼
 ┌────────────────────────────────────────────────────────┐
 │             GUBERNATOR CORE AUTH ENGINE (Go)           │
 │  - Local Emergency Admin (admin / admin fallback)      │
 │  - Multi-Server Active Directory / OpenLDAP Dialers    │
 │  - LDAPS (Port 636) &amp;amp; StartTLS (Port 389) Handshake    │
 │  - Dynamic Group DN -&amp;gt; RBAC Role Resolution            │
 │  - Cryptographic HMAC-SHA256 JWT Token Signing         │
 └─────────────┬────────────────────────────┬─────────────┘
               │                            │
               ▼                            ▼
 ┌───────────────────────────┐ ┌──────────────────────────┐
 │  Primary Active Directory │ │ Secondary LDAP Server    │
 │   dc1.corporate.local     │ │   dc2.dr-site.local      │
 └───────────────────────────┘ └──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Role-Based Access Control (RBAC) Matrix
&lt;/h3&gt;

&lt;p&gt;We established three distinct operational tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operational Capability&lt;/th&gt;
&lt;th&gt;&lt;code&gt;admin&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;operator&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;readonly&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overview, Metrics &amp;amp; SRE Telemetry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deploy Stacks (&lt;code&gt;docker-compose.yml&lt;/code&gt;)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Redeploy &amp;amp; Duplicate Stacks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delete Stacks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Task Lifecycle (Start / Stop / Restart)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container &amp;amp; Node Terminal Shell&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node Fleet Management (Drain / Activate / Leave)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Caddy TLS Certificates &amp;amp; Ingress Routes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Active Directory &amp;amp; LDAP Directory Settings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grafana, Jaeger &amp;amp; Weave Scope Dashboards&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  💻 The Go Backend Engine (&lt;code&gt;internal/auth/&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;For LDAP/Active Directory interactions, we used &lt;code&gt;github.com/go-ldap/ldap/v3&lt;/code&gt;, and for session management &lt;code&gt;github.com/golang-jwt/jwt/v5&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Two-Phase Bind &amp;amp; Credential Verification
&lt;/h3&gt;

&lt;p&gt;Authentication follows a secure two-phase pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect and perform a &lt;strong&gt;Service Account Bind&lt;/strong&gt; (&lt;code&gt;BindDN&lt;/code&gt; / &lt;code&gt;BindPassword&lt;/code&gt;) to query the directory.&lt;/li&gt;
&lt;li&gt;Search for the user object using a configurable LDAP filter (defaulting to &lt;code&gt;(&amp;amp;(objectClass=user)(sAMAccountName=%s))&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Open a secondary connection and perform a &lt;strong&gt;Direct User Bind&lt;/strong&gt; with the user-submitted password against the domain controller.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;AuthenticateLDAP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LDAPConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;AuthResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ConnectLDAP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c"&gt;// 1. Initial service account bind&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BindDN&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BindPassword&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BindDN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BindPassword&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"service account bind failed: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 2. Search for the user&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ldap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EscapeFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;searchReq&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ldap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSearchRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseDN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ldap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeWholeSubtree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ldap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NeverDerefAliases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"dn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"memberOf"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;searchReq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user not found in directory"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;userEntry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c"&gt;// 3. Direct user bind to verify password&lt;/span&gt;
    &lt;span class="n"&gt;userConn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ConnectLDAP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;userConn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;userConn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid credentials"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 4. Map groups to RBAC role&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAttributeValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"memberOf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ResolveRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;AuthResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;UserDN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Username&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DisplayName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAttributeValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAttributeValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mail"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Groups&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Dynamic Group-to-Role Mapping
&lt;/h3&gt;

&lt;p&gt;Gubernator inspects the user's &lt;code&gt;memberOf&lt;/code&gt; group list and matches them against the configured group DNs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ResolveRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LDAPConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userGroups&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Role&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;matchesGroup&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetGroup&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;targetGroup&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetGroup&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;userGroups&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matchesGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdminGroupDN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RoleAdmin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matchesGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperatorGroupDN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RoleOperator&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matchesGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadOnlyGroupDN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RoleReadOnly&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NormalizeRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Flutter Web UI Experience
&lt;/h2&gt;

&lt;p&gt;Gubernator's Web Dashboard is built with &lt;strong&gt;Flutter Web&lt;/strong&gt; and &lt;strong&gt;Material Design 3&lt;/strong&gt;, compiled and embedded directly into the Go binary (&lt;code&gt;go:embed&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Modern Login Screen with Domain Selector
&lt;/h3&gt;

&lt;p&gt;Operators can select their target authentication provider (&lt;code&gt;Corporate Active Directory&lt;/code&gt;, &lt;code&gt;DR Site LDAP&lt;/code&gt;, or &lt;code&gt;Local Administrator&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzko6ykwioskjaydd6fqg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzko6ykwioskjaydd6fqg.png" alt="Login Screen" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Active Directory Management &amp;amp; Diagnostics
&lt;/h3&gt;

&lt;p&gt;In the new &lt;strong&gt;Seguridad &amp;amp; AD&lt;/strong&gt; tab, cluster administrators can configure directory servers, TLS certificates, and run a live &lt;strong&gt;"Test Connection"&lt;/strong&gt; diagnostic tool:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0y8lv3qbyxlf6lxadyb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0y8lv3qbyxlf6lxadyb.png" alt="Security &amp;amp; AD Management" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Real-Time Role Badges &amp;amp; Contextual Guards
&lt;/h3&gt;

&lt;p&gt;The dashboard header displays the active user and their assigned role (&lt;code&gt;ADMIN&lt;/code&gt;, &lt;code&gt;⚡ OPERATOR&lt;/code&gt;, &lt;code&gt;READ-ONLY&lt;/code&gt;). Mutating actions (e.g., Delete Stack, Drain Node, Shell) are automatically disabled for read-only audit accounts.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Google Antigravity Accelerated Development
&lt;/h2&gt;

&lt;p&gt;We utilized &lt;strong&gt;Google Antigravity (AGY)&lt;/strong&gt; as an autonomous AI pair programmer to build this feature end-to-end. AGY accelerated the development cycle through several key workflows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Architectural Planning&lt;/strong&gt;:&lt;br&gt;
Before writing code, Antigravity produced a comprehensive implementation plan (&lt;code&gt;implementation_plan.md&lt;/code&gt;) outlining the GORM schema changes (&lt;code&gt;LDAPConfig&lt;/code&gt;), RBAC authorization matrix, and API routes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Synchronized Full-Stack Implementation&lt;/strong&gt;:&lt;br&gt;
In a single coordinated session, Antigravity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Built the Go &lt;code&gt;internal/auth/&lt;/code&gt; engine with LDAP dialers, JWT session handlers, and Gin middlewares.&lt;/li&gt;
&lt;li&gt;Applied SQLite database auto-migrations.&lt;/li&gt;
&lt;li&gt;Implemented the Flutter Web UI (&lt;code&gt;login_screen.dart&lt;/code&gt;, &lt;code&gt;security_page.dart&lt;/code&gt;, and state models).&lt;/li&gt;
&lt;li&gt;Updated existing views (&lt;code&gt;legions_page.dart&lt;/code&gt;, &lt;code&gt;tasks_page.dart&lt;/code&gt;, &lt;code&gt;centurions_page.dart&lt;/code&gt;) with RBAC permission guards.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Live Cluster Testing &amp;amp; Verification&lt;/strong&gt;:&lt;br&gt;
Using automated commands across a 3-node multipass cluster (&lt;code&gt;gbnt-manager&lt;/code&gt;, &lt;code&gt;gbnt-worker1&lt;/code&gt;, &lt;code&gt;gbnt-worker2&lt;/code&gt;), Antigravity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deployed and hot-restarted the ARM64 binaries.&lt;/li&gt;
&lt;li&gt;Tested REST endpoints via &lt;code&gt;curl&lt;/code&gt; (valid login, invalid login, LDAP connection tests, configuration lifecycle).&lt;/li&gt;
&lt;li&gt;Executed Go unit tests (&lt;code&gt;go test ./internal/auth/...&lt;/code&gt;) with 100% pass rates.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Automated Documentation &amp;amp; Release&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generated high-fidelity visual UI showcases.&lt;/li&gt;
&lt;li&gt;Authored complete documentation in &lt;a href="https://mario-ezquerro.github.io/gubernator/auth-rbac/" rel="noopener noreferrer"&gt;&lt;code&gt;docs/auth-rbac.md&lt;/code&gt;&lt;/a&gt; and validated MkDocs builds in strict mode.&lt;/li&gt;
&lt;li&gt;Bumped the version to &lt;code&gt;v2.20.0&lt;/code&gt;, created git release tags, and triggered GitHub Pages publishing.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Conclusion &amp;amp; Open Source
&lt;/h2&gt;

&lt;p&gt;Adding Active Directory SSO and RBAC allows teams to deploy Gubernator in enterprise production environments that require enterprise security compliance without the operational overhead of Kubernetes.&lt;/p&gt;

&lt;p&gt;Check out Gubernator and try it out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; &lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/mario-ezquerro/gubernator" rel="noopener noreferrer"&gt;github.com/mario-ezquerro/gubernator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Official Documentation:&lt;/strong&gt; &lt;a href="https://mario-ezquerro.github.io/gubernator/" rel="noopener noreferrer"&gt;mario-ezquerro.github.io/gubernator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Active Directory &amp;amp; RBAC Guide:&lt;/strong&gt; &lt;a href="https://mario-ezquerro.github.io/gubernator/auth-rbac/" rel="noopener noreferrer"&gt;docs/auth-rbac.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What do you think about this hybrid approach to container orchestration? Let us know your thoughts and suggestions in the comments! &lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>flutter</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building an AI Assistant: flutter nodejs ai browser automation</title>
      <dc:creator>Umair Bilal</dc:creator>
      <pubDate>Mon, 17 Aug 2026 04:37:20 +0000</pubDate>
      <link>https://dev.to/umair24171/building-an-ai-assistant-flutter-nodejs-ai-browser-automation-533d</link>
      <guid>https://dev.to/umair24171/building-an-ai-assistant-flutter-nodejs-ai-browser-automation-533d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://www.buildzn.com/blog/building-an-ai-assistant-flutter-nodejs-ai-browser-automation" rel="noopener noreferrer"&gt;BuildZn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Figured out how to tame web automation with an LLM. Everyone talks about AI agents, but getting them to reliably interact with dynamic web pages through a full-stack setup is a whole different beast. Spent weeks wrestling with flaky selectors and race conditions. Here's the blueprint that finally clicked for my personal AI assistant build using Flutter, Node.js, and browser automation. This setup slashed my daily busywork by a solid 60%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Full-Stack AI Browser Automation?
&lt;/h2&gt;

&lt;p&gt;My initial goal was simple: stop wasting time on repetitive online tasks. Think filling out expense reports, aggregating data from specific sites, or managing content on platforms without proper APIs. I needed a &lt;strong&gt;personal AI assistant build&lt;/strong&gt; that could understand high-level commands, translate them into browser actions, and then report back. This isn't just about scripting; it's about an LLM making &lt;em&gt;decisions&lt;/em&gt; based on current page state and a broader goal.&lt;/p&gt;

&lt;p&gt;I looked at a few options. Pure Python? Nah, I'm a Flutter guy, wanted a native UI. JavaScript-only? Possible, but I prefer Node.js for backend heavy lifting and orchestration. So, the stack solidified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Flutter:&lt;/strong&gt; For the cross-platform UI. Desktop support for Windows/macOS was key for a desktop assistant. This gives us &lt;strong&gt;flutter desktop automation&lt;/strong&gt; capabilities on the client side.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Node.js:&lt;/strong&gt; The brain. This is where the AI agent logic lives, handles API calls to LLMs, and orchestrates Playwright. Essentially, our &lt;strong&gt;nodejs playwright agent&lt;/strong&gt; server.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Playwright:&lt;/strong&gt; The hands. Robust, fast, and handles modern web elements way better than Puppeteer for my needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core challenge? Bridging the LLM's high-level reasoning with the nitty-gritty of browser interactions. Getting an AI to decide "click this specific button" or "fill this form field" when the page layout changes, or elements appear dynamically, that's where the real work is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: Orchestrating LLM Decisions with Browser Actions
&lt;/h2&gt;

&lt;p&gt;Here's the setup, simplified:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Flutter UI:&lt;/strong&gt; User sends a command (e.g., "Summarize unread emails from Project X in Gmail").&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Node.js Backend (API):&lt;/strong&gt; Receives the command.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Initial LLM Call (Planner):&lt;/strong&gt; The backend sends the command to an LLM (e.g., Claude 3.5 Sonnet, or OpenAI's GPT-4o). This "Planner" LLM identifies the &lt;em&gt;initial high-level steps&lt;/em&gt;. For Gmail, it might be "1. Navigate to Gmail. 2. Log in. 3. Find unread emails. 4. Filter for Project X. 5. Extract summaries."&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Action Executor Loop:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;  Node.js initializes Playwright.&lt;/li&gt;
&lt;li&gt;  For each step from the Planner, Node.js tells Playwright to perform an action (e.g., &lt;code&gt;await page.goto('https://gmail.com')&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Observation/Reflection (LLM Call - Actuator):&lt;/strong&gt; After each action, Node.js grabs the current page content (or specific elements). This observation, along with the &lt;em&gt;overall goal&lt;/em&gt; and &lt;em&gt;previous steps&lt;/em&gt;, is sent back to the LLM. The "Actuator" LLM's job is to decide the &lt;em&gt;next precise browser action&lt;/em&gt; (e.g., &lt;code&gt;click&lt;/code&gt; on &lt;code&gt;[aria-label="Email address"]&lt;/code&gt;, &lt;code&gt;fill&lt;/code&gt; with &lt;code&gt;myemail@gmail.com&lt;/code&gt;, &lt;code&gt;press&lt;/code&gt; 'Enter').&lt;/li&gt;
&lt;li&gt;  This loop continues until the overall goal is met or an error occurs.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Result Reporting:&lt;/strong&gt; Once the task is done, the extracted data or status is sent back to the Flutter UI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Key Components &amp;amp; Their Roles:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Flutter (Client):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Sends user intents via HTTP requests to Node.js.&lt;/li&gt;
&lt;li&gt;  Displays real-time status updates and final results.&lt;/li&gt;
&lt;li&gt;  Provides a simple UI for configuration and task management. It's truly a &lt;strong&gt;cross platform ai assistant&lt;/strong&gt; client.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Node.js (Backend/Agent Orchestrator):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Express API:&lt;/strong&gt; Handles requests from Flutter.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;LLM Integration:&lt;/strong&gt; Uses &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; or &lt;code&gt;openai&lt;/code&gt; libraries. I used Claude 3.5 Sonnet for its cost-effectiveness and context window.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Playwright:&lt;/strong&gt; &lt;code&gt;@playwright/test&lt;/code&gt; for browser control.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Task Management:&lt;/strong&gt; Simple state machine to track ongoing browser sessions and agent steps.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Building the Brain: Node.js, LLMs, and Playwright
&lt;/h2&gt;

&lt;p&gt;Let's get into the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Flutter UI (Simplified)
&lt;/h3&gt;

&lt;p&gt;On the Flutter side, it's pretty standard HTTP stuff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/services/ai_service.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:convert'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:http/http.dart'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AIService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'http://localhost:3000/api/agent'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Your Node.js backend&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;runBrowserTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;taskDescription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$baseUrl&lt;/span&gt;&lt;span class="s"&gt;/start'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;headers:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'Content-Type'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;'task'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;taskDescription&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'result'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'Task completed.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;'Error: &lt;/span&gt;&lt;span class="si"&gt;${response.statusCode}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;${response.body}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;'Network error: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In your Flutter widget:&lt;/span&gt;
&lt;span class="c1"&gt;// import 'package:your_app/services/ai_service.dart';&lt;/span&gt;
&lt;span class="c1"&gt;// final aiService = AIService();&lt;/span&gt;
&lt;span class="c1"&gt;// String result = await aiService.runBrowserTask("Go to Google, search 'FarahGPT', click first link, tell me the title.");&lt;/span&gt;
&lt;span class="c1"&gt;// print(result);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This just kicks off the task. The real magic happens on Node.js.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Node.js Backend &amp;amp; Agent Loop
&lt;/h3&gt;

&lt;p&gt;First, set up a basic Express server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Or OpenAI&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Make sure you set this!&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// A simple in-memory store for ongoing sessions (for demo)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activeSessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/agent/start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Task description is required.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Unique session ID&lt;/span&gt;
  &lt;span class="nx"&gt;activeSessions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAgentTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;activeSessions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// Clean up&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Agent task failed for session &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;activeSessions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Node.js AI agent backend listening on http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ... rest of the agent logic below&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, the &lt;code&gt;runAgentTask&lt;/code&gt; function, which orchestrates the LLM and Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// agent.js (or integrated into server.js)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// Re-init for clarity&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAgentTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;activeSessions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Session not found.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// Change to false for debugging&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentObservation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Goal: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initialTask&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Current URL: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_STEPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Prevent infinite loops&lt;/span&gt;

  &lt;span class="c1"&gt;// Initial planning phase (optional, can be merged into the main loop)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialPlan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-3-5-sonnet-20240620&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are an expert browser automation agent. Your goal is: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initialTask&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;". Based on this, what are the high-level steps to achieve this? List them clearly, e.g., "1. Navigate to X. 2. Login. 3. Do Y.". Then, what is the very first specific browser action I should take? Be precise.`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Initial Plan:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialPlan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;initialPlan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;


  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_STEPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Add current page state to observation&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pageContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Get the outer HTML of the body or a relevant part&lt;/span&gt;
      &lt;span class="nx"&gt;pageContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outerHTML&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not get page content:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;pageContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Error getting page content: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;currentObservation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Current URL: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;\nPage content snapshot (truncated): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\nGoal: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initialTask&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nWhat is the NEXT precise browser action to take? Respond ONLY with a JSON object. Format: {"action": "goto"|"click"|"fill"|"type"|"evaluate"|"extract"|"finish", "target": "selector_or_url", "value": "text_to_fill_or_js_code", "reason": "why this action"}. If the goal is achieved, use "finish" action with the extracted result in "value".`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Previous interactions for context&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentObservation&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`--- Step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ---`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sending to LLM, current URL:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;llmResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-3-5-sonnet-20240620&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;responseText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;llmResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LLM Raw Response:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;responseText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;responseText&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;responseText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```/g, '')); // Clean markdown code blocks
    } catch (e) {
      console.error('Failed to parse LLM action JSON:', e);
      // Fallback or request LLM to retry
      history.push({"role": "user", "content": "Error: Your last response was not valid JSON. Please provide a valid JSON action."});
      continue;
    }

    console('Parsed Action:', action);

    if (action.action === 'finish') {
      console.log('Agent finished task.');
      await browser.close();
      return action.value;
    }

    try {
      switch (action.action) {
        case 'goto':
          await page.goto(action.target, { waitUntil: 'domcontentloaded' });
          break;
        case 'click':
          await page.click(action.target, { timeout: 5000 }); // Add timeout for flaky elements
          break;
        case 'fill':
          await page.fill(action.target, action.value, { timeout: 5000 });
          // Hard Rule: Playwright 1.40.0+ on type="number" inputs can be weird.
          // If you fill a number input with JS handlers and it doesn't trigger change,
          // sometimes a manual dispatchEvent or a tab press helps.
          // Example: If filling `&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`, and the JS on blur
          // doesn't trigger, you might need:
          // await page.dispatchEvent(action.target, 'input');
          // I found this specifically with some custom Angular/React number inputs.
          // For simpler cases, page.fill is usually fine.
          break;
        case 'type': // More human-like typing
          await page.type(action.target, action.value, { delay: 100 });
          break;
        case 'evaluate': // Run custom JS on the page
          const evalResult = await page.evaluate(action.value);
          currentObservation = `&lt;/span&gt;&lt;span class="nx"&gt;Evaluated&lt;/span&gt; &lt;span class="na"&gt;JS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;evalResult&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`;
          break;
        case 'extract': // Extract specific data
          const extractedData = await page.$eval(action.target, el =&amp;gt; el.textContent);
          currentObservation = `&lt;/span&gt;&lt;span class="nx"&gt;Extracted&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;extractedData&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`;
          // If this is the final extraction before finishing, LLM should propose 'finish' next.
          break;
        default:
          throw new Error(`&lt;/span&gt;&lt;span class="nx"&gt;Unknown&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`);
      }
      await page.waitForTimeout(1000); // Give page a moment to settle
      // Update observation with new page state for next LLM call
      currentObservation = `&lt;/span&gt;&lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;${action.action}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;${action.target}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;`;
    } catch (e) {
      console.error(`&lt;/span&gt;&lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;&lt;span class="s2"&gt;`, e);
      history.push({"role": "user", "content": `&lt;/span&gt;&lt;span class="na"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Failed&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;perform&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;${action.action}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;${action.target}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="na"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt; &lt;span class="nx"&gt;Current&lt;/span&gt; &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()}.&lt;/span&gt; &lt;span class="nx"&gt;Please&lt;/span&gt; &lt;span class="nx"&gt;suggest&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;alternative&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nx"&gt;again&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;impossible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;`});
    }
  }

  await browser.close();
  return "Task reached max steps without completion.";
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;code&gt;runAgentTask&lt;/code&gt; function is the core of the &lt;strong&gt;flutter nodejs ai browser automation&lt;/strong&gt;. It iteratively calls the LLM, parses the LLM's suggested action, executes it with Playwright, and then feeds the new page state back to the LLM for the next decision. It's a classic LLM agent "plan, act, observe, reflect" loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Wrong First
&lt;/h2&gt;

&lt;p&gt;Honestly, getting the prompt right for the Actuator LLM was the biggest pain. I started with open-ended prompts like "What should I do next?". Big mistake. The LLM would generate paragraphs, or sometimes just chat back, rather than a structured action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix:&lt;/strong&gt; &lt;strong&gt;Strict JSON output for actions.&lt;/strong&gt;&lt;br&gt;
By explicitly telling Claude "Respond ONLY with a JSON object. Format: {...}", it drastically improved reliability. If it messed up the JSON, my Node.js code would catch it, and I'd feed that parsing error &lt;em&gt;back into the LLM's context&lt;/em&gt; as a new user message: &lt;code&gt;"Error: Your last response was not valid JSON. Please provide a valid JSON action."&lt;/code&gt; This self-correction mechanism is crucial.&lt;/p&gt;

&lt;p&gt;Another massive headache was handling dynamic web elements. Sometimes &lt;code&gt;page.click('button#submit')&lt;/code&gt; would fail because the button wasn't ready, or an overlay covered it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;page.waitForSelector()&lt;/code&gt;&lt;/strong&gt; before interaction, often with &lt;code&gt;state: 'visible'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Increased timeouts&lt;/strong&gt; on Playwright actions (e.g., &lt;code&gt;click({ timeout: 5000 })&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;LLM reflection on failure:&lt;/strong&gt; When Playwright throws an error, I catch it and feed the error message &lt;em&gt;and&lt;/em&gt; the current page state (HTML snippet, URL) back to the LLM. The LLM can then &lt;em&gt;decide&lt;/em&gt; to try a different selector, wait longer, or navigate elsewhere. This is what truly differentiates a scripted bot from an agent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One specific issue I hit with Playwright 1.40.0: when filling certain &lt;code&gt;&amp;lt;input type="number"&amp;gt;&lt;/code&gt; fields, especially if they had custom JavaScript validation or formatting on blur, &lt;code&gt;page.fill()&lt;/code&gt; wouldn't always trigger the necessary change events. The form would look filled, but the internal application state wouldn't update.&lt;br&gt;
&lt;strong&gt;The Fix:&lt;/strong&gt; After &lt;code&gt;page.fill(selector, value)&lt;/code&gt;, I sometimes had to explicitly call &lt;code&gt;await page.dispatchEvent(selector, 'input')&lt;/code&gt; or even &lt;code&gt;await page.press(selector, 'Tab')&lt;/code&gt; to force the blur event. This isn't documented as a common necessity for &lt;code&gt;page.fill&lt;/code&gt;, but it saved my sanity on specific SPA frameworks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quantifying the Time Saved
&lt;/h2&gt;

&lt;p&gt;Before this &lt;strong&gt;flutter nodejs ai browser automation&lt;/strong&gt; setup, I spent roughly 3-4 hours a week on repetitive tasks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Collecting specific product data from competitor websites.&lt;/li&gt;
&lt;li&gt;  Filling out complex internal forms for project updates.&lt;/li&gt;
&lt;li&gt;  Aggregating analytics data from various dashboards that lack direct API access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, I just type a command into my Flutter app: "Scrape product details for X from site Y and put it in a Notion table." or "Fill out my weekly report for Project Z with these bullet points."&lt;/p&gt;

&lt;p&gt;The agent handles it. It navigates, logs in (using secure environment variables), extracts, fills, and even uploads if needed. &lt;strong&gt;This literally cut down 2.5-3 hours of that busywork a week. That's a 60-75% reduction on those tasks.&lt;/strong&gt; The time saved allows me to focus on actual development and strategic thinking, not mind-numbing clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Q: Can I run Playwright headlessly on a remote server?
&lt;/h3&gt;

&lt;p&gt;A: Absolutely. Playwright is designed for both headless and headful execution. For production deployments, you'll almost always run it headlessly on a Linux server (e.g., a Vercel serverless function or a dedicated EC2 instance). Just make sure your server environment has the necessary browser dependencies installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: How do you handle authentication (logins) securely?
&lt;/h3&gt;

&lt;p&gt;A: Never hardcode credentials. For a personal assistant, store sensitive info (usernames, passwords) in environment variables or a secure vault (like Google Secret Manager, AWS Secrets Manager, or a simple &lt;code&gt;.env&lt;/code&gt; file for dev, encrypted for prod). Your Node.js backend can retrieve these securely and pass them to Playwright for login forms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: What's the biggest bottleneck for this type of agent?
&lt;/h3&gt;

&lt;p&gt;A: The LLM's latency and its ability to consistently produce correct, actionable JSON. Even with Claude 3.5 Sonnet, which is fast, a complex task with many steps means multiple round trips to the LLM, adding up. If the LLM produces invalid JSON, it adds another round trip for correction. This is where good prompt engineering and robust error handling are critical.&lt;/p&gt;

&lt;p&gt;This setup is a game-changer for personal productivity. Honestly, I don't get why more developers aren't building these specialized &lt;strong&gt;cross platform ai assistant&lt;/strong&gt; tools for their own specific workflows. The combination of Flutter for a native feel, Node.js for backend agility, and Playwright for robust web interaction, all powered by a smart LLM, is incredibly powerful. Stop clicking, start automating.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>node</category>
      <category>aiagents</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Why BlocSignal Doesn't Need Provider (And Why Classic BLoC Always Did)</title>
      <dc:creator>Randal L. Schwartz</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:12:39 +0000</pubDate>
      <link>https://dev.to/gde/why-blocsignal-doesnt-need-provider-and-why-classic-bloc-always-did-1j3g</link>
      <guid>https://dev.to/gde/why-blocsignal-doesnt-need-provider-and-why-classic-bloc-always-did-1j3g</guid>
      <description>&lt;h2&gt;
  
  
  How shedding package:provider eliminates dependency hell, fixes Flutter's lingering ghost rebuild bug, and delivers fine-grained synchronous reactivity in 2026.
&lt;/h2&gt;

&lt;p&gt;If you browse &lt;a href="https://www.reddit.com/r/FlutterDev/" rel="noopener noreferrer"&gt;r/FlutterDev&lt;/a&gt; on any given week, you will find the exact same architectural debate playing out:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Should I use BLoC or Riverpod for my next production app? BLoC has great structure and discipline, but the stream boilerplate is overwhelming. Riverpod is reactive and flexible, but the &lt;code&gt;@riverpod&lt;/code&gt; code generation and constant version transitions make it feel heavyweight."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And inevitably, someone in the comments will chime in:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I just stick with plain &lt;code&gt;package:provider&lt;/code&gt; because it's simple and doesn't require code-gen."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This trilemma—&lt;strong&gt;BLoC vs. Riverpod vs. Provider&lt;/strong&gt;—has defined Flutter state management for over six years. But behind this debate lies a little-known architectural secret that explains why Flutter state management felt so fractured in the first place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classic &lt;code&gt;flutter_bloc&lt;/code&gt; was secretly just &lt;code&gt;package:provider&lt;/code&gt; in disguise.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's look at why classic BLoC relied on &lt;code&gt;package:provider&lt;/code&gt;, the hidden runtime bugs and dependency deadlocks that came with it, why Riverpod had to break away, and how &lt;strong&gt;&lt;a href="https://blocsignal.dev" rel="noopener noreferrer"&gt;BlocSignal&lt;/a&gt;&lt;/strong&gt; delivers the ultimate resolution: &lt;strong&gt;zero provider, zero streams, and zero code generation.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Look Under the Hood: Classic BLoC's Hidden Dependency
&lt;/h2&gt;

&lt;p&gt;When developers think of Felix Angelov’s classic &lt;code&gt;flutter_bloc&lt;/code&gt;, they think of Streams, Sinks, and unidirectional event architectures. But if you open &lt;code&gt;flutter_bloc/pubspec.yaml&lt;/code&gt;, you'll find a foundational dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bloc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^8.1.4&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^6.0.5&lt;/span&gt; &lt;span class="c1"&gt;# 👈 The hidden foundation!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In classic &lt;code&gt;flutter_bloc&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BlocProvider&amp;lt;T&amp;gt;&lt;/code&gt; is literally an extension of &lt;code&gt;package:provider&lt;/code&gt;'s &lt;code&gt;InheritedProvider&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MultiBlocProvider&lt;/code&gt; is just a thin alias over &lt;code&gt;MultiProvider&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RepositoryProvider&lt;/code&gt; is literally &lt;code&gt;Provider&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Did Classic BLoC Do This?
&lt;/h3&gt;

&lt;p&gt;Back in 2018–2019, writing custom &lt;code&gt;InheritedWidget&lt;/code&gt; plumbing in Flutter was verbose and error-prone. Rémi Rousselet’s &lt;code&gt;package:provider&lt;/code&gt; was the newly crowned Google-recommended solution for dependency injection and widget tree scoping. &lt;/p&gt;

&lt;p&gt;Building &lt;code&gt;flutter_bloc&lt;/code&gt; on top of &lt;code&gt;package:provider&lt;/code&gt; allowed BLoC to focus on its stream state machine while outsourcing widget tree scoping, lazy instantiation, and disposal to Provider.&lt;/p&gt;

&lt;p&gt;It seemed like a great shortcut. But over time, coupling BLoC to &lt;code&gt;package:provider&lt;/code&gt; introduced two massive architectural headaches.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Two Fatal Flaws of the Provider Foundation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Your Application ] ──► [ flutter_bloc ]
                            │
                            └──► [ package:provider ] ──► [ Transitive Version Lock ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Flaw #1: "Dependency Hell" &amp;amp; Version Lockouts
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;package:provider&lt;/code&gt; is one of the most widely used packages in the Flutter ecosystem, major version updates (such as migrating from &lt;code&gt;v4&lt;/code&gt; to &lt;code&gt;v5&lt;/code&gt; to &lt;code&gt;v6&lt;/code&gt; for null safety) created widespread dependency deadlocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Because my_app depends on:
  - legacy_auth_plugin ^2.1.0 (which depends on provider ^5.0.0)
  - flutter_bloc ^8.0.0 (which depends on provider ^6.0.5)

Version solving failed:
Cannot solve dependencies because provider ^5.0.0 is incompatible with provider ^6.0.5!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Flutter developer has experienced this nightmare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You couldn't upgrade &lt;code&gt;flutter_bloc&lt;/code&gt; because an analytics or payment SDK pinned an older &lt;code&gt;provider&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Teams were forced to use risky &lt;code&gt;dependency_overrides:&lt;/code&gt; in &lt;code&gt;pubspec.yaml&lt;/code&gt; and pray that internal breaking changes wouldn't crash production builds.&lt;/li&gt;
&lt;li&gt;Engineers had to fork third-party repositories just to bump a &lt;code&gt;provider&lt;/code&gt; constraint.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Flaw #2: The "Lingering Dependency" (Ghost Rebuild) Bug
&lt;/h3&gt;

&lt;p&gt;This is the deepest, most subtle flaw in Flutter's &lt;code&gt;InheritedWidget&lt;/code&gt; system—and it was the primary catalyst that drove Rémi Rousselet to abandon Provider and create Riverpod.&lt;/p&gt;

&lt;p&gt;When an &lt;code&gt;Element&lt;/code&gt; calls &lt;code&gt;context.watch&amp;lt;T&amp;gt;()&lt;/code&gt; or &lt;code&gt;Provider.of&amp;lt;T&amp;gt;(context)&lt;/code&gt;, Flutter registers that &lt;code&gt;Element&lt;/code&gt; as a dependent of the ancestor &lt;code&gt;InheritedWidget&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fatal catch:&lt;/strong&gt; Flutter’s engine &lt;strong&gt;never unregisters&lt;/strong&gt; an element from an &lt;code&gt;InheritedWidget&lt;/code&gt; on subsequent builds! Dependencies are only cleared when the widget is completely unmounted.&lt;/p&gt;

&lt;p&gt;Consider this common conditional UI pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👴 The Classic Provider Ghost Rebuild Trap:&lt;/span&gt;
&lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isExpanded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 🚩 Registers a permanent dependency on DetailsModel&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DetailsModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;FullDetailsCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 👻 GHOST REBUILD: Even when collapsed, this widget STILL rebuilds &lt;/span&gt;
    &lt;span class="c1"&gt;// on every single change to DetailsModel forever!&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CompactSummaryCard&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;isExpanded&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; even once, Flutter permanently binds &lt;code&gt;DetailsModel&lt;/code&gt; to that widget. When the card collapses, &lt;strong&gt;it continues to rebuild on every &lt;code&gt;DetailsModel&lt;/code&gt; emission indefinitely&lt;/strong&gt;, wasting CPU cycles, battery, and rendering frames on state it isn't even displaying!&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The Riverpod Exodus: Escaping the Widget Tree
&lt;/h2&gt;

&lt;p&gt;Rémi recognized that Flutter's &lt;code&gt;InheritedWidget&lt;/code&gt; and &lt;code&gt;BuildContext&lt;/code&gt; had fundamental limitations that could not be fixed within &lt;code&gt;package:provider&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You couldn't easily read state outside the widget tree (for example, in background services or pure Dart logic).&lt;/li&gt;
&lt;li&gt;The lingering dependency bug caused unavoidable ghost rebuilds on conditional branches.&lt;/li&gt;
&lt;li&gt;Combining two providers required ugly nested widget hierarchies or manual proxies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So Rémi built &lt;strong&gt;Riverpod&lt;/strong&gt; (&lt;code&gt;ProviderContainer&lt;/code&gt;), moving the entire dependency and state graph &lt;strong&gt;outside&lt;/strong&gt; of the Flutter widget tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Riverpod Got Complicated
&lt;/h3&gt;

&lt;p&gt;While Riverpod solved the &lt;code&gt;BuildContext&lt;/code&gt; coupling, it created a new set of challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;@riverpod&lt;/code&gt; Code-Gen Dogmatism:&lt;/strong&gt; To avoid writing boilerplate notifiers, developers were pushed toward &lt;code&gt;build_runner&lt;/code&gt; and code generation. If you didn't run a file watcher in the background, development ground to a halt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex Internal Types:&lt;/strong&gt; Behind a simple provider was a labyrinth of generated classes (&lt;code&gt;AutoDisposeAsyncNotifierProviderElement&lt;/code&gt;, &lt;code&gt;ProviderFamily&lt;/code&gt;, &lt;code&gt;AsyncValue&lt;/code&gt; edge cases).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-World Impedance:&lt;/strong&gt; Managing state in an external container while rendering in Flutter's &lt;code&gt;Element&lt;/code&gt; tree required complex retention counters (&lt;code&gt;autoDispose&lt;/code&gt;, &lt;code&gt;disposeDelay&lt;/code&gt;, &lt;code&gt;cacheTime&lt;/code&gt;) to guess when widgets were truly done using state.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. The BlocSignal Resolution: Zero Provider, Zero Code-Gen
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────────────────────────┐
│                              BlocSignal                                │
│                                                                        │
│   ┌────────────────────────┐              ┌────────────────────────┐   │
│   │   The Rigor of BLoC    │              │  The Speed of Signals  │   │
│   │  • Unidirectional flow │              │  • Synchronous DAG     │   │
│   │  • Explicit Events     │      ➕      │  • Dynamic Pruning     │   │
│   │  • Strict Transitions  │              │  • Zero Streams        │   │
│   │  • 100% Traceability   │              │  • Zero Code-Gen       │   │
│   └────────────────────────┘              └────────────────────────┘   │
└────────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://blocsignal.dev" rel="noopener noreferrer"&gt;BlocSignal&lt;/a&gt;&lt;/strong&gt; resolves this historical progression by rethinking the state primitive from the ground up:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Native O(1) &lt;code&gt;InheritedWidget&lt;/code&gt; (Zero Third-Party Dependencies)
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;bloc_signals_flutter&lt;/code&gt;, &lt;code&gt;BlocSignalProvider&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; depend on &lt;code&gt;package:provider&lt;/code&gt;. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is built directly on Flutter's core SDK &lt;code&gt;InheritedWidget&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It performs instant O(1) lookups via &lt;code&gt;getElementForInheritedWidgetOfExactType&lt;/code&gt; without intermediate proxy nodes or delegating elements.&lt;/li&gt;
&lt;li&gt;It has &lt;strong&gt;zero external dependencies&lt;/strong&gt;—eliminating &lt;code&gt;pub get&lt;/code&gt; version deadlocks permanently.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2. Dynamic Per-Frame Dependency Pruning (No Ghost Rebuilds)
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;BlocSignal&lt;/code&gt; is powered by fine-grained Signals (&lt;code&gt;signals_flutter&lt;/code&gt;), dependencies are tracked &lt;strong&gt;dynamically on every single evaluation frame&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ⚡ In BlocSignal: Zero Ghost Rebuilds!&lt;/span&gt;
&lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Watch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isExpanded&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// ✅ Subscribes to detailsCubit in this frame&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;FullDetailsCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detailsCubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ When false, detailsCubit is AUTOMATICALLY UNWATCHED and detached!&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CompactSummaryCard&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;isExpanded&lt;/code&gt; turns &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;detailsCubit&lt;/code&gt; is &lt;strong&gt;immediately pruned and unwatched&lt;/strong&gt;. If &lt;code&gt;detailsCubit&lt;/code&gt; mutates while the card is collapsed, &lt;strong&gt;zero rebuilds occur&lt;/strong&gt;. You get pristine, leak-free reactivity without code generation or external containers.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Synchronous State Propagation (No Stream Queue Latency)
&lt;/h3&gt;

&lt;p&gt;Classic BLoC emits state over Dart asynchronous microtask Streams. Every state change yields to the event loop before reaching the screen.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;BlocSignal&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calling &lt;code&gt;emit(newState)&lt;/code&gt; updates the underlying &lt;code&gt;ReadonlySignal&amp;lt;State&amp;gt;&lt;/code&gt; &lt;strong&gt;synchronously in the exact same frame&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The GPU and widget tree render the new state with zero microtask queue hops and zero 1-frame loading flickers.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  4. Streamless BLoC-to-BLoC Coordination
&lt;/h3&gt;

&lt;p&gt;In classic BLoC, coordinating two Blocs requires nesting &lt;code&gt;BlocListener&lt;/code&gt; widgets in the UI tree or writing complex Rx stream pipelines.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;BlocSignal&lt;/code&gt;, because state is a Signal, containers can observe each other directly in pure business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CartCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CubitSignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CartState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;CartCubit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authCubit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CartInitial&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Synchronously react to auth changes without UI BlocListeners:&lt;/span&gt;
    &lt;span class="n"&gt;createEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authCubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Unauthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;clearCart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;AuthCubit&lt;/span&gt; &lt;span class="n"&gt;authCubit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. The 4-Way Code Shootout
&lt;/h2&gt;

&lt;p&gt;Let's look at how the exact same Counter feature looks across all four paradigms:&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: Classic &lt;code&gt;Provider&lt;/code&gt; (&lt;code&gt;ChangeNotifier&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CounterModel&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ChangeNotifier&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;notifyListeners&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 🚩 Easy to forget; triggers blanket rebuilds&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: Classic &lt;code&gt;flutter_bloc&lt;/code&gt; (Streams + &lt;code&gt;package:provider&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CounterCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Cubit&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;CounterCubit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ⏳ Asynchronous stream microtask&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option C: Riverpod 3 (Code Generation + &lt;code&gt;build_runner&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@riverpod&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;_$Counter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ⚙️ Requires running build_runner&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option D: Modern &lt;code&gt;BlocSignal&lt;/code&gt; (Pure, Synchronous Dart)
&lt;/h3&gt;

&lt;h4&gt;
  
  
  In Dart 3.5 (Baseline Syntax):
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CounterCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CubitSignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;CounterCubit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ⚡ Synchronous, zero code-gen&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  In Dart 3.13 (Modern Primary Constructor):
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nf"&gt;CounterCubit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CubitSignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. The Ultimate Comparison Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;code&gt;package:provider&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Classic &lt;code&gt;flutter_bloc&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;Riverpod&lt;/code&gt; 3&lt;/th&gt;
&lt;th&gt;&lt;code&gt;BlocSignal&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core Reactive Engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ChangeNotifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asynchronous &lt;code&gt;Stream&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;External DAG&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Synchronous &lt;code&gt;Signal&lt;/code&gt; DAG&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Depends on &lt;code&gt;provider&lt;/code&gt;?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;YES&lt;/strong&gt; (&lt;code&gt;^6.0.0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;NO (Pure SDK)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Requires Code-Gen?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;YES&lt;/strong&gt; (&lt;code&gt;@riverpod&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ZERO Code-Gen&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ghost Rebuild Fix?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ (Leaks on branch)&lt;/td&gt;
&lt;td&gt;❌ (Inherited leak)&lt;/td&gt;
&lt;td&gt;✅ (External Graph)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✅ (Dynamic Graph Pruning)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Immutability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ (Mutable fields)&lt;/td&gt;
&lt;td&gt;✅ (Immutable State)&lt;/td&gt;
&lt;td&gt;✅ (Immutable State)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✅ (&lt;code&gt;ReadonlySignal&lt;/code&gt;)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Execution Timing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronous&lt;/td&gt;
&lt;td&gt;Asynchronous microtask&lt;/td&gt;
&lt;td&gt;Synchronous&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Synchronous (Same Frame)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenTelemetry &amp;amp; Observers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ (&lt;code&gt;BlocObserver&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Partial (&lt;code&gt;ProviderObserver&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✅ (Otel + Observers)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-Container Sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clunky Proxies&lt;/td&gt;
&lt;td&gt;Nested UI Listeners&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ref.watch()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;createEffect&lt;/code&gt; / &lt;code&gt;computed&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  7. The 60-Second Refactor: Your AI Migration Playbook
&lt;/h2&gt;

&lt;p&gt;Five years ago, migrating a production app away from classic BLoC or Provider was a multi-month engineering slog. &lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;2026&lt;/strong&gt;, with modern AI coding assistants (Antigravity, Cursor, Copilot, Gemini) and &lt;code&gt;BlocSignal&lt;/code&gt;, the refactor is practically instantaneous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROMPT FOR YOUR AI ASSISTANT:
"Replace `flutter_bloc` with `bloc_signals_flutter`.
Replace `BlocProvider` with `BlocSignalProvider`.
Replace `BlocBuilder` with `BlocSignalBuilder`.
Remove `provider` from `pubspec.yaml` and run `flutter pub get`."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Automated AI Migration Skills:&lt;/strong&gt; The &lt;a href="https://github.com/RandalSchwartz/BlocSignal" rel="noopener noreferrer"&gt;BlocSignal repository&lt;/a&gt; even includes pre-packaged &lt;strong&gt;AI Agent Skills &amp;amp; Plugins&lt;/strong&gt; (under &lt;code&gt;plugins/bloc-signals/skills/bloc-signals/&lt;/code&gt;) for Antigravity, Cursor, Gemini CLI, and Claude Code. You can install the skill into your workspace to give your AI agent deep, rule-enforced expertise in migrating classic BLoC and Riverpod apps to BlocSignal with full test verification!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In 60 seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;All your Blocs and Cubits keep their exact same event and state models.&lt;/li&gt;
&lt;li&gt;The asynchronous stream microtask delay disappears.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;package:provider&lt;/code&gt; is wiped from your &lt;code&gt;pubspec.yaml&lt;/code&gt; forever.&lt;/li&gt;
&lt;li&gt;Your unit tests run synchronously with zero &lt;code&gt;pumpAndSettle()&lt;/code&gt; microtask draining hacks.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  8. Summary: Less is More
&lt;/h2&gt;

&lt;p&gt;Software architecture advances not by adding more layers of abstraction, but by removing the friction between your code and the metal.&lt;/p&gt;

&lt;p&gt;By removing &lt;code&gt;package:provider&lt;/code&gt; and replacing stream plumbing with fine-grained Signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You eliminate &lt;strong&gt;version collisions and dependency solver deadlocks&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You fix Flutter’s &lt;strong&gt;lingering dependency ghost rebuild bug&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You get &lt;strong&gt;synchronous, same-frame UI rendering&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You preserve &lt;strong&gt;100% of BLoC’s enterprise structure and event traceability&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's everything you loved about BLoC, everything you wanted from Riverpod, and all the simplicity of Provider—with none of the baggage.&lt;/p&gt;




&lt;h3&gt;
  
  
  🚀 Get Started with BlocSignal
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Official Website &amp;amp; Interactive Showcase:&lt;/strong&gt; &lt;a href="https://blocsignal.dev" rel="noopener noreferrer"&gt;blocsignal.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Core Pure-Dart Package:&lt;/strong&gt; &lt;a href="https://pub.dev/packages/bloc_signals" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals&lt;/code&gt; on pub.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📱 &lt;strong&gt;Flutter UI Package:&lt;/strong&gt; &lt;a href="https://pub.dev/packages/bloc_signals_flutter" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals_flutter&lt;/code&gt; on pub.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌊 &lt;strong&gt;Riverpod Interop Bridge:&lt;/strong&gt; &lt;a href="https://pub.dev/packages/bloc_signals_riverpod" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals_riverpod&lt;/code&gt; on pub.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;Open Source Monorepo:&lt;/strong&gt; &lt;a href="https://github.com/RandalSchwartz/BlocSignal" rel="noopener noreferrer"&gt;GitHub (RandalSchwartz/BlocSignal)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>statemanagement</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Offline-First Flutter: Syncing Local and Remote Data Reliably</title>
      <dc:creator>Bimal Kshetri</dc:creator>
      <pubDate>Sun, 16 Aug 2026 15:55:08 +0000</pubDate>
      <link>https://dev.to/bimal-py/offline-first-flutter-syncing-local-and-remote-data-reliably-4fdh</link>
      <guid>https://dev.to/bimal-py/offline-first-flutter-syncing-local-and-remote-data-reliably-4fdh</guid>
      <description>&lt;p&gt;Building an &lt;strong&gt;offline-first Flutter&lt;/strong&gt; app is less about caching the network and more about flipping the dependency: the local database becomes your source of truth, and the server becomes a replica you reconcile with later. I've shipped this pattern across several production iOS and Android apps, and the ones that treated the network as optional from day one were dramatically more reliable than the ones that bolted on caching after launch.&lt;/p&gt;

&lt;p&gt;This is a practical, example-driven guide for intermediate Flutter developers. We'll cover the architecture, picking a Flutter local database, designing a durable sync queue, choosing a conflict resolution strategy, and — the part most tutorials skip — handling the failures that actually happen in the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "offline-first" really means
&lt;/h2&gt;

&lt;p&gt;The phrase gets thrown around loosely, so let me be precise. An offline-first app has three non-negotiable properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The UI never blocks on the network.&lt;/strong&gt; Every read and write hits local storage and returns immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes are durable before they're sent.&lt;/strong&gt; A user's edit survives an app kill, a crash, or a dead connection because it's persisted locally first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync is a background reconciliation process&lt;/strong&gt;, not part of the user's interaction loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model I use: the user only ever talks to the local store. A separate &lt;strong&gt;sync engine&lt;/strong&gt; watches a queue of pending changes and negotiates with the server whenever it can. The two are decoupled. If you remember nothing else from this article, remember that the write path and the sync path must not be the same code path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI  ──►  Local DB (source of truth)  ──►  Sync queue  ──►  Remote API
 ▲            │                                              │
 └── streams ─┘                            reconcile ◄───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Choosing a Flutter local database
&lt;/h2&gt;

&lt;p&gt;Your local store is the foundation, so pick deliberately. For an offline-first design you want reactive queries (so the UI rebuilds when local data changes) and real transactions (so a write and its queue entry commit atomically). The two I reach for are &lt;strong&gt;Drift&lt;/strong&gt; and &lt;strong&gt;Isar&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Reactive queries&lt;/th&gt;
&lt;th&gt;Transactions&lt;/th&gt;
&lt;th&gt;Best when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Drift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Relational (SQLite)&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;watch()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Strong, SQL&lt;/td&gt;
&lt;td&gt;Relational data, complex queries, migrations matter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Isar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;NoSQL object store&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;watch()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Object graphs, very high read throughput, simple schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sqflite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Raw SQLite&lt;/td&gt;
&lt;td&gt;No (manual)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;You want full control and don't mind wiring reactivity yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Key-value&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Simple settings/blobs, not a sync backbone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For anything with relationships and a sync queue, I default to &lt;strong&gt;Drift&lt;/strong&gt;: SQL transactions let me write the row and enqueue the mutation in one atomic step, and &lt;code&gt;watch()&lt;/code&gt; gives me a &lt;code&gt;Stream&lt;/code&gt; the UI subscribes to. If your data is a big object graph and you care more about raw speed than joins, &lt;strong&gt;Isar&lt;/strong&gt; is excellent. Avoid Hive as a sync backbone — its lack of real transactions will bite you.&lt;/p&gt;

&lt;p&gt;Here's a minimal Drift schema with the two tables every offline-first app needs — the domain table and an &lt;strong&gt;outbox&lt;/strong&gt; for pending mutations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:drift/drift.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Todos&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt; &lt;span class="c1"&gt;// client-generated UUID&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;BoolColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;
  &lt;span class="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt; &lt;span class="c1"&gt;// epoch millis, our version clock&lt;/span&gt;
  &lt;span class="n"&gt;BoolColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;primaryKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Outbox&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;autoIncrement&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;    &lt;span class="c1"&gt;// e.g. 'todo'&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;entityId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;        &lt;span class="c1"&gt;// 'upsert' | 'delete'&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;   &lt;span class="c1"&gt;// JSON snapshot&lt;/span&gt;
  &lt;span class="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, &lt;strong&gt;IDs are generated on the client&lt;/strong&gt; (a UUID), not the server. This lets a user create records offline that already have stable identity. Second, &lt;code&gt;updatedAt&lt;/code&gt; doubles as a logical version clock for conflict detection later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the sync queue (the outbox pattern)
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;outbox pattern&lt;/strong&gt; is the heart of a reliable sync engine. Instead of firing an HTTP request when the user edits something, you write the change to local storage &lt;em&gt;and&lt;/em&gt; append a record to the outbox table — atomically, in one transaction. A background worker drains the outbox.&lt;/p&gt;

&lt;p&gt;The atomicity is the whole point. If you write the row but the app dies before you enqueue the sync job, the server never hears about it. A single transaction makes "data changed" and "needs sync" inseparable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;upsertTodo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;updatedAt:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;millisecondsSinceEpoch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// toCompanion(true) maps nulls to Value.absent() rather than explicit null.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insertOnConflictUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toCompanion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OutboxCompanion&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;entity:&lt;/span&gt; &lt;span class="s"&gt;'todo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;entityId:&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;op:&lt;/span&gt; &lt;span class="s"&gt;'upsert'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toJson&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI calls &lt;code&gt;upsertTodo&lt;/code&gt;, the local &lt;code&gt;watch()&lt;/code&gt; stream emits, and the screen updates instantly — no network involved. Now the draining side. Process the outbox &lt;strong&gt;in order&lt;/strong&gt;, oldest &lt;code&gt;seq&lt;/code&gt; first, so causally dependent changes (create then update) reach the server in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;drainOutbox&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;OrderingTerm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;ConflictException&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_resolveConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;serverRecord&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;RetryableException&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
          &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OutboxCompanion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;attempts:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stop the batch; preserve ordering, retry later&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the three outcomes: &lt;strong&gt;success&lt;/strong&gt; removes the entry, a &lt;strong&gt;conflict&lt;/strong&gt; resolves and removes it, and a &lt;strong&gt;retryable failure&lt;/strong&gt; bumps the attempt counter and stops the batch so we don't reorder later changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflict resolution: last-write-wins vs. merge
&lt;/h2&gt;

&lt;p&gt;The hard part of &lt;strong&gt;offline-first Flutter&lt;/strong&gt; is what happens when the same record was edited in two places. There's no universally correct answer — only trade-offs you choose deliberately per entity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Last-write-wins (LWW)
&lt;/h3&gt;

&lt;p&gt;The simplest strategy: each record carries a version/timestamp, and the most recent write overwrites the older one. It's easy to reason about and stateless. The cost is &lt;strong&gt;silent data loss&lt;/strong&gt; — if two people edited different fields, one set of edits vanishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="nf"&gt;resolveLww&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LWW is the right call for low-contention, single-user-per-record data: a user's own settings, a draft only they edit, a "last read position." Use a monotonic clock you control (server-assigned timestamps or a logical counter), because device clocks lie and skew.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field-level merge
&lt;/h3&gt;

&lt;p&gt;When losing edits is unacceptable, merge at the &lt;strong&gt;field level&lt;/strong&gt;. You compare the local version, the remote version, and ideally a common ancestor (the value at last sync) to detect which side actually changed each field. If only one side touched a field, take that side; if both changed the same field, you have a true conflict to escalate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="kd"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Three-way merge for a single field: returns the winning value.&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// both sides agree&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// only remote changed&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// only local changed&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// both changed: policy — prefer remote, or flag it&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;mergedDone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt;
      &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kd"&gt;base&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;base&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nl"&gt;done:&lt;/span&gt; &lt;span class="n"&gt;mergedDone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For collaborative, structured data where even field merges aren't enough, &lt;strong&gt;CRDTs&lt;/strong&gt; (conflict-free replicated data types) let independent replicas converge automatically without a central referee. They're powerful but add real complexity — reach for them only when concurrent editing of shared documents is a core feature, not before.&lt;/p&gt;

&lt;p&gt;Here's how I choose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Data loss risk&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Last-write-wins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High (whole record)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Single-owner records, settings, drafts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Field-level merge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Forms edited from multiple devices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CRDT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (converges)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Real-time collaborative documents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Handling failures and partial sync
&lt;/h2&gt;

&lt;p&gt;A sync engine that only works on a perfect connection isn't offline-first — it's an online app in denial. Plan for the messy reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exponential backoff with jitter.&lt;/strong&gt; Don't hammer a failing server. Back off geometrically and add randomness so a fleet of devices doesn't reconnect in a thundering herd:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:math'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Cap the base at 30s; everything stays int so Duration is happy.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nextInt&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kd"&gt;base&lt;/span&gt; &lt;span class="o"&gt;~/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="kd"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Idempotent writes.&lt;/strong&gt; A request can succeed on the server but fail before the client sees the &lt;code&gt;200&lt;/code&gt;, so the client retries. Send a client-generated request key (your outbox &lt;code&gt;entityId&lt;/code&gt; plus a content hash works) and have the server deduplicate. With idempotency, "did this actually go through?" stops being a question you have to answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poison messages.&lt;/strong&gt; A mutation that fails forever — a deleted parent, a validation the server rejects — will block the queue if you let it. Cap attempts, move the entry to a dead-letter table, surface it, and keep draining the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attempts&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_moveToDeadLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trigger sync on the right events.&lt;/strong&gt; Drain the outbox on app start, when &lt;code&gt;connectivity_plus&lt;/code&gt; reports a connection returning, when the app comes back to the foreground, and after every local write (debounced). Treat connectivity as a &lt;em&gt;hint&lt;/em&gt;, not a guarantee — a "connected" Wi-Fi with no real internet is common, so the real test is whether a request succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull as well as push.&lt;/strong&gt; Everything above covers pushing local changes up. You also need to pull remote changes down — ideally a delta endpoint that returns records changed since a stored cursor (&lt;code&gt;updatedSince&lt;/code&gt;), then apply each through the same conflict resolver before writing locally. One reconciliation path, used in both directions, keeps the logic honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a sync lifecycle
&lt;/h2&gt;

&lt;p&gt;A clean offline-first Flutter app ends up with a small, predictable loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; from the local DB via reactive streams — the UI never waits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write&lt;/strong&gt; locally and enqueue to the outbox in one transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push&lt;/strong&gt; the outbox in &lt;code&gt;seq&lt;/code&gt; order with backoff, idempotency, and dead-lettering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull&lt;/strong&gt; deltas since the last cursor and reconcile through your conflict resolver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve&lt;/strong&gt; conflicts per entity — LWW for single-owner data, merge for shared.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep each stage independent and testable. The biggest reliability win in my experience isn't a clever algorithm — it's the discipline of making the local store authoritative and never letting the network leak into the write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Offline-first isn't a feature you sprinkle on; it's an architecture: a local source of truth, a transactional outbox, a deliberate conflict policy, and failure handling that assumes the network will betray you. Get those four right and your app feels instant on a subway and bulletproof on flaky hotel Wi-Fi.&lt;/p&gt;

&lt;p&gt;If you're building something like this and want a second pair of hands, &lt;a href="https://bimalkhatri.com.np/hire" rel="noopener noreferrer"&gt;work with me&lt;/a&gt; or &lt;a href="https://bimalkhatri.com.np/#contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; — I'm always happy to talk shop about sync engines.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>flutter</category>
      <category>offline</category>
    </item>
    <item>
      <title>Flutter CI/CD with GitHub Actions and Fastlane: A Real Pipeline</title>
      <dc:creator>Bimal Kshetri</dc:creator>
      <pubDate>Sun, 16 Aug 2026 15:55:02 +0000</pubDate>
      <link>https://dev.to/bimal-py/flutter-cicd-with-github-actions-and-fastlane-a-real-pipeline-4do4</link>
      <guid>https://dev.to/bimal-py/flutter-cicd-with-github-actions-and-fastlane-a-real-pipeline-4do4</guid>
      <description>&lt;p&gt;Flutter CI/CD stops being optional the moment you ship to two stores from one codebase and a manual release eats half a day. In this post I walk through the exact pipeline I run on production apps: GitHub Actions runs &lt;code&gt;flutter analyze&lt;/code&gt; and tests on every pull request, then — only when I push a version tag — it builds signed iOS and Android artifacts and uploads them to TestFlight and the Google Play internal track. No clicking through Xcode Organizer, no dragging &lt;code&gt;.aab&lt;/code&gt; files into a browser.&lt;/p&gt;

&lt;p&gt;I'll cover the workflow YAML, the Fastlane lanes that do the store uploads, and the part everyone gets wrong the first time: getting signing material — the Android keystore and the iOS distribution certificate — into CI without leaking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split CI and CD on a tag
&lt;/h2&gt;

&lt;p&gt;The single most useful decision in a Flutter CI/CD setup is separating two concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI (every PR):&lt;/strong&gt; fast, cheap, runs on Linux, gates merges. Format check, &lt;code&gt;flutter analyze&lt;/code&gt;, unit and widget tests. This should finish in a couple of minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CD (on a tag):&lt;/strong&gt; slow, expensive (macOS minutes for iOS), produces signed builds, talks to the stores. You only want this when you actually intend to release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tying release to a Git tag like &lt;code&gt;v1.4.0&lt;/code&gt; gives you a clean, auditable trigger. The tag &lt;em&gt;is&lt;/em&gt; the release record. Pushing to &lt;code&gt;main&lt;/code&gt; shouldn't ship anything — that's how you end up with surprise TestFlight builds at 2am.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Runs on&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ubuntu-latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;format, analyze, test&lt;/td&gt;
&lt;td&gt;Low / fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag &lt;code&gt;v*.*.*&lt;/code&gt; (Android)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ubuntu-latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;signed &lt;code&gt;.aab&lt;/code&gt; to Play internal&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag &lt;code&gt;v*.*.*&lt;/code&gt; (iOS)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;macos-latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;signed &lt;code&gt;.ipa&lt;/code&gt; to TestFlight&lt;/td&gt;
&lt;td&gt;High (macOS minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep iOS on its own job. macOS runners bill at roughly ten times the per-minute rate of Linux runners on GitHub-hosted machines, so you don't want every PR burning them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI job: analyze and test on every PR
&lt;/h2&gt;

&lt;p&gt;Here's the lint-and-test half of the workflow. It pins the Flutter version (never rely on &lt;code&gt;latest&lt;/code&gt; — a Dart SDK bump can break your build on an unrelated PR) and caches pub dependencies so reruns are quick.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;analyze-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subosito/flutter-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;flutter-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.27.1"&lt;/span&gt;
          &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stable&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter pub get&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify formatting&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dart format --output=none --set-exit-if-changed .&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Analyze&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter analyze --fatal-infos&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter test --coverage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I insist on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dart format --set-exit-if-changed&lt;/code&gt;&lt;/strong&gt; fails the build on unformatted code. It ends the "whitespace-only diff" wars permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--fatal-infos&lt;/code&gt;&lt;/strong&gt; treats analyzer infos as failures. If your &lt;code&gt;analysis_options.yaml&lt;/code&gt; flags something, the PR should be red. Half-honored lint rules are worse than none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cache: true&lt;/code&gt;&lt;/strong&gt; on &lt;code&gt;flutter-action&lt;/code&gt; caches the SDK and the pub cache between runs, which shaves a minute or more off most runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run integration tests or golden tests, gate the expensive ones behind a separate job or a label so day-to-day PRs stay fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fastlane: lanes that talk to the stores
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;flutter build&lt;/code&gt; produces the artifact; Fastlane handles &lt;em&gt;delivery&lt;/em&gt; — uploading that artifact to the right track with the right credentials. You keep two &lt;code&gt;fastlane&lt;/code&gt; folders, one under &lt;code&gt;android/&lt;/code&gt; and one under &lt;code&gt;ios/&lt;/code&gt;, each with its own &lt;code&gt;Fastfile&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Android: upload to the Play Store internal track
&lt;/h3&gt;

&lt;p&gt;For Android you authenticate with a &lt;strong&gt;Google Cloud service account&lt;/strong&gt; JSON that has been granted access in the Play Console. The &lt;code&gt;upload_to_play_store&lt;/code&gt; action (the &lt;code&gt;supply&lt;/code&gt; integration) does the upload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# android/fastlane/Fastfile&lt;/span&gt;
&lt;span class="n"&gt;default_platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:android&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:android&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Upload a signed AAB to the Play Store internal track"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:internal&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;upload_to_play_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;track: &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;aab: &lt;/span&gt;&lt;span class="s2"&gt;"../build/app/outputs/bundle/release/app-release.aab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;json_key_data: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PLAY_STORE_SERVICE_ACCOUNT_JSON"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;release_status: &lt;/span&gt;&lt;span class="s2"&gt;"draft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_apk: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_metadata: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_images: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_screenshots: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pass the service-account JSON as raw data via &lt;code&gt;json_key_data&lt;/code&gt; (read from an env var) rather than a file on disk, so nothing sensitive is ever written to the runner's filesystem. &lt;code&gt;release_status: "draft"&lt;/code&gt; leaves a human to do the final promote — automate the upload, keep the &lt;em&gt;publish&lt;/em&gt; deliberate.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS: upload to TestFlight
&lt;/h3&gt;

&lt;p&gt;For iOS, &lt;code&gt;upload_to_testflight&lt;/code&gt; (Pilot) talks to App Store Connect. Use an &lt;strong&gt;App Store Connect API key&lt;/strong&gt;, not your Apple ID and an app-specific password — API keys don't trip two-factor auth and don't break the moment Apple decides your session looks suspicious.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ios/fastlane/Fastfile&lt;/span&gt;
&lt;span class="n"&gt;default_platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:ios&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:ios&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Upload the signed IPA to TestFlight"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:beta&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;app_store_connect_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;key_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_KEY_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;issuer_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_ISSUER_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;key_content: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_KEY_CONTENT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# base64-encoded .p8&lt;/span&gt;
      &lt;span class="ss"&gt;is_key_content_base64: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;upload_to_testflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;ipa: &lt;/span&gt;&lt;span class="s2"&gt;"../build/ios/ipa/Runner.ipa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_waiting_for_build_processing: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app_store_connect_api_key&lt;/code&gt; stores the key in Fastlane's shared lane context, so &lt;code&gt;upload_to_testflight&lt;/code&gt; picks it up automatically — no need to thread the hash through by hand. &lt;code&gt;skip_waiting_for_build_processing: true&lt;/code&gt; returns as soon as the upload finishes instead of blocking the runner (and your billed minutes) while Apple processes the build. The trade-off: you can't auto-assign testers in the same run, which is fine — I let a later step or TestFlight's own settings handle distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets and signing material without leaking them
&lt;/h2&gt;

&lt;p&gt;This is where Flutter CI/CD pipelines go wrong. You can't commit your keystore or &lt;code&gt;.p8&lt;/code&gt;, and you can't &lt;code&gt;flutter build&lt;/code&gt; a release without your signing material. The bridge is &lt;strong&gt;GitHub Actions encrypted secrets&lt;/strong&gt; plus base64 encoding for binary files.&lt;/p&gt;

&lt;p&gt;Encode the binaries locally once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Android upload keystore&lt;/span&gt;
&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; upload-keystore.jks | pbcopy   &lt;span class="c"&gt;# paste into secret KEYSTORE_BASE64&lt;/span&gt;

&lt;span class="c"&gt;# App Store Connect API key (.p8)&lt;/span&gt;
&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; AuthKey_ABC123.p8 | pbcopy      &lt;span class="c"&gt;# paste into secret ASC_KEY_CONTENT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add these repository secrets (Settings, then Secrets and variables, then Actions):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEYSTORE_BASE64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;base64 of the upload keystore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;KEYSTORE_PASSWORD&lt;/code&gt; / &lt;code&gt;KEY_PASSWORD&lt;/code&gt; / &lt;code&gt;KEY_ALIAS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;signing config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLAY_STORE_SERVICE_ACCOUNT_JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;full JSON, pasted raw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ASC_KEY_ID&lt;/code&gt; / &lt;code&gt;ASC_ISSUER_ID&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;from App Store Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ASC_KEY_CONTENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;base64 of the &lt;code&gt;.p8&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MATCH_GIT_URL&lt;/code&gt; / &lt;code&gt;MATCH_PASSWORD&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;certs repo + decryption passphrase&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the runner, decode the keystore into place and have Gradle read the signing config from environment variables, so nothing is hardcoded in &lt;code&gt;build.gradle&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYSTORE_BASE64&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; android/app/upload-keystore.jks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// android/app/build.gradle&lt;/span&gt;
&lt;span class="n"&gt;android&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;signingConfigs&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;storeFile&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"KEYSTORE_PATH"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s2"&gt;"upload-keystore.jks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;storePassword&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"KEYSTORE_PASSWORD"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;keyAlias&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"KEY_ALIAS"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;keyPassword&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"KEY_PASSWORD"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;file("upload-keystore.jks")&lt;/code&gt; path resolves relative to &lt;code&gt;android/app/&lt;/code&gt;, which is exactly where the decode step writes it.&lt;/p&gt;

&lt;p&gt;For iOS code signing in CI, the cleanest path is &lt;strong&gt;Fastlane Match&lt;/strong&gt;, which stores your distribution certificate and provisioning profiles encrypted in a private Git repo and installs them on the runner. This matters more than it looks: a bare &lt;code&gt;flutter build ipa&lt;/code&gt; on a fresh macOS runner has no certificate in its keychain and will fail to sign. Add a &lt;code&gt;sync_certificates&lt;/code&gt; (Match) lane and call it before the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ios/fastlane/Fastfile  (add inside platform :ios)&lt;/span&gt;
&lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Install distribution signing assets via Match"&lt;/span&gt;
&lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:certificates&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;setup_ci&lt;/span&gt; &lt;span class="c1"&gt;# creates a temporary keychain on CI&lt;/span&gt;
  &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"appstore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;readonly: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;app_store_connect_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;key_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_KEY_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;issuer_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_ISSUER_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;key_content: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_KEY_CONTENT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;is_key_content_base64: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setup_ci&lt;/code&gt; spins up a throwaway keychain so the imported certificate doesn't linger, and &lt;code&gt;readonly: true&lt;/code&gt; means CI consumes existing certs but never mints new ones (certificate creation stays a deliberate local action). If you're not on Match yet you can import a base64 &lt;code&gt;.p12&lt;/code&gt; into a temporary keychain by hand, but Match scales better across machines and teammates, and it's what I reach for on any app with more than one engineer.&lt;/p&gt;

&lt;p&gt;Two rules I never break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;echo&lt;/code&gt; a secret to stdout.&lt;/strong&gt; GitHub masks known secret values in logs, but a base64 round-trip or a substring can slip through. Pipe directly into &lt;code&gt;base64 --decode&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope credentials minimally.&lt;/strong&gt; The Play service account only needs release-manager permission on that one app; the ASC API key can be limited in role. Least privilege contains the blast radius if a token leaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The release job: build signed artifacts on a tag
&lt;/h2&gt;

&lt;p&gt;Now the CD half. This triggers only on version tags and runs Android and iOS as parallel jobs. Note the iOS job is the only one on &lt;code&gt;macos-latest&lt;/code&gt;, and it installs signing assets via Match &lt;em&gt;before&lt;/em&gt; the build.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*.*.*"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subosito/flutter-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;flutter-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.27.1"&lt;/span&gt;
          &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stable&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruby/setup-ruby@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ruby-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.3"&lt;/span&gt;
          &lt;span class="na"&gt;bundler-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Decode keystore&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "$KEYSTORE_BASE64" | base64 --decode &amp;gt; android/app/upload-keystore.jks&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;KEYSTORE_BASE64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.KEYSTORE_BASE64 }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build AAB&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter build appbundle --release&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;KEYSTORE_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.KEYSTORE_PASSWORD }}&lt;/span&gt;
          &lt;span class="na"&gt;KEY_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.KEY_PASSWORD }}&lt;/span&gt;
          &lt;span class="na"&gt;KEY_ALIAS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.KEY_ALIAS }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload to Play Store&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle exec fastlane internal&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PLAY_STORE_SERVICE_ACCOUNT_JSON&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}&lt;/span&gt;

  &lt;span class="na"&gt;ios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;macos-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subosito/flutter-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;flutter-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.27.1"&lt;/span&gt;
          &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stable&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruby/setup-ruby@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ruby-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.3"&lt;/span&gt;
          &lt;span class="na"&gt;bundler-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ios&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install signing assets (Match)&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ios&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle exec fastlane certificates&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ASC_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;ASC_ISSUER_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_ISSUER_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;ASC_KEY_CONTENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_KEY_CONTENT }}&lt;/span&gt;
          &lt;span class="na"&gt;MATCH_GIT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MATCH_GIT_URL }}&lt;/span&gt;
          &lt;span class="na"&gt;MATCH_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MATCH_PASSWORD }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build IPA&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter build ipa --release --export-options-plist=ios/ExportOptions.plist&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload to TestFlight&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ios&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle exec fastlane beta&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ASC_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;ASC_ISSUER_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_ISSUER_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;ASC_KEY_CONTENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ASC_KEY_CONTENT }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of notes that save real debugging time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin your version source.&lt;/strong&gt; Pass &lt;code&gt;--build-name&lt;/code&gt; and &lt;code&gt;--build-number&lt;/code&gt; derived from the tag (for example, strip the &lt;code&gt;v&lt;/code&gt; and feed the rest to &lt;code&gt;--build-name&lt;/code&gt;, and use &lt;code&gt;${{ github.run_number }}&lt;/code&gt; for &lt;code&gt;--build-number&lt;/code&gt;) so the binary's version matches the Git tag exactly. A TestFlight build whose version doesn't match its tag is a forensic nightmare later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit a &lt;code&gt;Gemfile&lt;/code&gt;&lt;/strong&gt; in both &lt;code&gt;android/&lt;/code&gt; and &lt;code&gt;ios/&lt;/code&gt; and use &lt;code&gt;bundle exec fastlane&lt;/code&gt; — this pins the Fastlane version so a Fastlane release doesn't silently change behavior mid-flight. &lt;code&gt;bundler-cache: true&lt;/code&gt; makes the install cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--export-options-plist&lt;/code&gt;&lt;/strong&gt; controls the iOS export method (&lt;code&gt;app-store&lt;/code&gt; for TestFlight). Generate it once, check it in, and don't let Flutter guess. The team identifier and signing style in that plist must line up with what Match installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Automate Flutter releases without losing the safety rails
&lt;/h2&gt;

&lt;p&gt;The goal isn't to remove humans from releases — it's to remove &lt;em&gt;toil&lt;/em&gt; so the human decisions are the only ones left. To automate Flutter releases responsibly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the final &lt;strong&gt;publish/promote&lt;/strong&gt; manual (&lt;code&gt;release_status: "draft"&lt;/code&gt;, internal track first). The pipeline gets a build into testers' hands; a person decides when it goes to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail loudly.&lt;/strong&gt; If signing fails, the whole job should go red — never let a half-uploaded release look green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make tagging the only release ritual.&lt;/strong&gt; &lt;code&gt;git tag v1.4.0 &amp;amp;&amp;amp; git push origin v1.4.0&lt;/code&gt; should be the entire release procedure. If it isn't, you've left a manual step that someone will eventually forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoke-test the artifact&lt;/strong&gt; where you can — install the internal-track build on a real device before promoting. CI proves it builds and signs; it doesn't prove it runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once this is wired up, GitHub Actions Flutter releases get boring in the best way: open a PR, get green checks, merge, tag, and walk away while TestFlight and the Play Store internal track fill up on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;A solid Flutter CI/CD pipeline is mostly discipline encoded as YAML: fast checks on every PR, slow signed builds only on a tag, and signing material handled so nothing sensitive ever lands on disk in plaintext. Get those three right and releasing two apps collapses into a single &lt;code&gt;git push&lt;/code&gt;. I've stood this exact pipeline up on production iOS and Android apps more than a few times — if you'd like help wiring it into your project you can &lt;a href="https://bimalkhatri.com.np/hire" rel="noopener noreferrer"&gt;hire me&lt;/a&gt;, or just &lt;a href="https://bimalkhatri.com.np/#contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and we'll talk through your setup.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>flutter</category>
    </item>
    <item>
      <title>Clean Architecture in Flutter with BLoC: A Practical Guide</title>
      <dc:creator>Bimal Kshetri</dc:creator>
      <pubDate>Sun, 16 Aug 2026 15:36:26 +0000</pubDate>
      <link>https://dev.to/bimal-py/clean-architecture-in-flutter-with-bloc-a-practical-guide-npa</link>
      <guid>https://dev.to/bimal-py/clean-architecture-in-flutter-with-bloc-a-practical-guide-npa</guid>
      <description>&lt;p&gt;Clean architecture in Flutter is the single biggest reason the production apps I ship stay maintainable after a year of feature churn. Over 4+ years building iOS and Android apps, I've watched "just put the logic in the widget" turn &lt;code&gt;setState&lt;/code&gt; spaghetti into a codebase nobody wants to touch. This guide walks through how I actually split a Flutter app into &lt;strong&gt;domain&lt;/strong&gt;, &lt;strong&gt;data&lt;/strong&gt;, and &lt;strong&gt;presentation&lt;/strong&gt; layers with BLoC — using one concrete feature so you can copy the structure into your own project today.&lt;/p&gt;

&lt;p&gt;I'll build a small "Todos" feature end to end: an entity, a use case, a repository with a DTO mapper, and a Cubit that drives the UI. The point isn't the todo list — it's the &lt;strong&gt;boundaries&lt;/strong&gt; between layers and why each one earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why clean architecture in Flutter pays off
&lt;/h2&gt;

&lt;p&gt;The core idea is the &lt;strong&gt;dependency rule&lt;/strong&gt;: source-code dependencies point &lt;em&gt;inward&lt;/em&gt;. The UI knows about the domain; the domain knows about nothing. Your business rules never import Flutter, Firebase, Dio, or Supabase.&lt;/p&gt;

&lt;p&gt;That inversion buys three things I care about on every project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testability.&lt;/strong&gt; Domain logic runs in plain Dart unit tests — no widget pump, no emulator, no network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable infrastructure.&lt;/strong&gt; Move from REST to GraphQL, or Firestore to a local SQLite cache, by rewriting one data-layer class. The domain and UI don't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel work.&lt;/strong&gt; Once the domain contract exists, one person builds the API client while another builds the screen against a fake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the layer breakdown I use, and what's allowed to live in each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Knows about&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;th&gt;Depends on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Domain&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing external&lt;/td&gt;
&lt;td&gt;Entities, repository &lt;em&gt;interfaces&lt;/em&gt;, use cases&lt;/td&gt;
&lt;td&gt;Pure Dart only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Domain + the outside world&lt;/td&gt;
&lt;td&gt;DTOs, mappers, repository &lt;em&gt;implementations&lt;/em&gt;, data sources&lt;/td&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Presentation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;Blocs/Cubits, states, widgets&lt;/td&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the data and presentation layers both depend on domain, and domain depends on neither. That's the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Folder structure that scales
&lt;/h2&gt;

&lt;p&gt;I organise &lt;strong&gt;by feature first, then by layer&lt;/strong&gt;. A flat &lt;code&gt;models/&lt;/code&gt;, &lt;code&gt;services/&lt;/code&gt;, &lt;code&gt;screens/&lt;/code&gt; split looks tidy on day one and becomes a scavenger hunt by feature five. Feature-first keeps everything you touch for one change in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib/
  features/
    todos/
      domain/
        entities/todo.dart
        repositories/todo_repository.dart   # abstract
        usecases/get_todos.dart
      data/
        models/todo_dto.dart                # JSON -&amp;gt; DTO + mapper
        datasources/todo_remote_data_source.dart
        repositories/todo_repository_impl.dart
      presentation/
        cubit/todos_cubit.dart
        cubit/todos_state.dart
        pages/todos_page.dart
  core/
    error/failures.dart
    usecases/usecase.dart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;core/&lt;/code&gt; directory holds cross-feature primitives — failure types, a base &lt;code&gt;UseCase&lt;/code&gt; contract, shared extensions. Everything feature-specific stays inside its feature folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The domain layer: entities and use cases
&lt;/h2&gt;

&lt;p&gt;The domain is pure Dart. No &lt;code&gt;package:flutter&lt;/code&gt;, no JSON, no annotations. Start with the &lt;strong&gt;entity&lt;/strong&gt; — the shape your app reasons about, not the shape the API returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// domain/entities/todo.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:equatable/equatable.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Todo&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Equatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;isCompleted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isCompleted&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I extend &lt;code&gt;Equatable&lt;/code&gt; so two todos with the same fields compare equal — that makes Cubit state comparisons and tests painless.&lt;/p&gt;

&lt;p&gt;Next, the &lt;strong&gt;repository interface&lt;/strong&gt;. This is the contract the domain &lt;em&gt;demands&lt;/em&gt; from the outside world. It lives in domain, but it's implemented in data. That's the dependency inversion that keeps Firebase or Dio out of your business rules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// domain/repositories/todo_repository.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dartz/dartz.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../../../core/error/failures.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../entities/todo.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I return &lt;code&gt;Either&amp;lt;Failure, T&amp;gt;&lt;/code&gt; from &lt;code&gt;dartz&lt;/code&gt; instead of throwing across layers. Failures become &lt;strong&gt;values you must handle&lt;/strong&gt;, not exceptions that silently bubble into the UI. &lt;code&gt;Failure&lt;/code&gt; is a sealed type in &lt;code&gt;core/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// core/error/failures.dart&lt;/span&gt;
&lt;span class="kd"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Failure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServerFailure&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Failure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ServerFailure&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'Something went wrong'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NetworkFailure&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Failure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NetworkFailure&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'No internet connection'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the &lt;strong&gt;use case&lt;/strong&gt;. A use case is one application action with a single public method. It reads almost like a sentence: &lt;em&gt;get todos&lt;/em&gt;. This is where orchestration lives — call a repository, maybe combine two, apply a business rule — without the Cubit ever knowing how data is fetched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// core/usecases/usecase.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dartz/dartz.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../error/failures.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UseCase&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoParams&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NoParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// domain/usecases/get_todos.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dartz/dartz.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../../../core/error/failures.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../../../core/usecases/usecase.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../entities/todo.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../repositories/todo_repository.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetTodos&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;UseCase&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;NoParams&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;GetTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_repository&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;TodoRepository&lt;/span&gt; &lt;span class="n"&gt;_repository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NoParams&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;People ask whether use cases are overkill when they just forward to a repository. Early on, many do — and that's a fair criticism. But the &lt;em&gt;seam&lt;/em&gt; is what matters: the day "get todos" needs to filter archived items, merge a local cache, or log analytics, you change one file and every caller benefits. That discipline is what makes the domain/data/presentation split scale instead of rot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data layer: DTOs, mappers, and the repository
&lt;/h2&gt;

&lt;p&gt;The data layer is where the messy outside world lives — and where it stays. The key move is a &lt;strong&gt;DTO&lt;/strong&gt; (data transfer object) that mirrors the JSON exactly, plus a &lt;strong&gt;mapper&lt;/strong&gt; that converts it to a clean domain entity. Never let raw JSON shapes leak into your domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/models/todo_dto.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../domain/entities/todo.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;factory&lt;/span&gt; &lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;completed:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'is_done'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Todo&lt;/span&gt; &lt;span class="n"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;isCompleted:&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the value here: the API uses &lt;code&gt;is_done&lt;/code&gt;, my domain uses &lt;code&gt;isCompleted&lt;/code&gt;, and the API might send &lt;code&gt;null&lt;/code&gt;. All of that ugliness is quarantined in one mapper. If the backend renames a field tomorrow, exactly one file changes.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;data source&lt;/strong&gt; owns the transport — Dio, http, Firestore — and speaks DTOs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/datasources/todo_remote_data_source.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dio/dio.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../models/todo_dto.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoRemoteDataSource&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fetchTodos&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoRemoteDataSourceImpl&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;TodoRemoteDataSource&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodoRemoteDataSourceImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_dio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Dio&lt;/span&gt; &lt;span class="n"&gt;_dio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fetchTodos&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_dio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;'/todos'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TodoDto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, the &lt;strong&gt;repository implementation&lt;/strong&gt; stitches it together: call the data source, map DTOs to entities, and convert exceptions into &lt;code&gt;Failure&lt;/code&gt; values. This is the only class that implements the domain's &lt;code&gt;TodoRepository&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/repositories/todo_repository_impl.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dartz/dartz.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dio/dio.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../../../core/error/failures.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../domain/entities/todo.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../domain/repositories/todo_repository.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../datasources/todo_remote_data_source.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoRepositoryImpl&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;TodoRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodoRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_remote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;TodoRemoteDataSource&lt;/span&gt; &lt;span class="n"&gt;_remote&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;dtos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchTodos&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dtos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;DioException&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NetworkFailure&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ServerFailure&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Omitted for brevity — same try / map / Either shape as getTodos.&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;UnimplementedError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the &lt;strong&gt;repository pattern&lt;/strong&gt; doing its real job: the domain asked for &lt;code&gt;List&amp;lt;Todo&amp;gt;&lt;/code&gt; or a typed failure, and got exactly that — with every Dio detail absorbed here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The presentation layer: a Cubit driving the UI
&lt;/h2&gt;

&lt;p&gt;For most screens I reach for &lt;strong&gt;Cubit&lt;/strong&gt; over the full event-based &lt;strong&gt;Bloc&lt;/strong&gt;. Cubit is simpler — you call methods directly and emit states — and it covers the majority of UI logic. I switch to a Bloc when I genuinely need an event stream: debouncing search input, transforming concurrent events, or keeping an audit trail of what triggered each transition.&lt;/p&gt;

&lt;p&gt;I model state as a &lt;strong&gt;sealed class&lt;/strong&gt;, so the widget layer is forced to handle every case and the compiler catches the one I forgot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// presentation/cubit/todos_state.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:equatable/equatable.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../domain/entities/todo.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Equatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosInitial&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosInitial&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosLoading&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosLoading&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosLoaded&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Cubit depends only on the &lt;strong&gt;use case&lt;/strong&gt; — never on a repository or Dio directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// presentation/cubit/todos_cubit.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_bloc/flutter_bloc.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../../../core/usecases/usecase.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'../../domain/usecases/get_todos.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'todos_state.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodosCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Cubit&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodosState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_getTodos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosInitial&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;GetTodos&lt;/span&gt; &lt;span class="n"&gt;_getTodos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosLoading&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_getTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NoParams&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TodosError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TodosLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the widget switches over the sealed state — exhaustively, with no &lt;code&gt;default&lt;/code&gt; branch needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// presentation/pages/todos_page.dart (excerpt)&lt;/span&gt;
&lt;span class="n"&gt;BlocBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;TodosInitial&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;TodosLoading&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;CircularProgressIndicator&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="n"&gt;TodosError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;TodosLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ListView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;itemCount:&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;itemBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CheckboxListTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;value:&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nl"&gt;onChanged:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring it all together happens once, at the composition root, with a DI container like &lt;code&gt;get_it&lt;/code&gt; (here &lt;code&gt;sl&lt;/code&gt; is the shared &lt;code&gt;GetIt&lt;/code&gt; service-locator instance). Each layer receives its inward dependency through the constructor — that's what makes everything mockable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;sl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetIt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerLazySingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoRemoteDataSource&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TodoRemoteDataSourceImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerLazySingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TodoRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerLazySingleton&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GetTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerFactory&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sl&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How clean architecture in Flutter wins on testing
&lt;/h2&gt;

&lt;p&gt;Here's the payoff. Because every dependency is an interface injected through a constructor, I can test each layer in isolation with a fake — no Flutter, no network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test/todos_cubit_test.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_test/bloc_test.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:dartz/dartz.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:mocktail/mocktail.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_test/flutter_test.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MockGetTodos&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Mock&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;GetTodos&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="n"&gt;MockGetTodos&lt;/span&gt; &lt;span class="n"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getTodos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MockGetTodos&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="n"&gt;setUpAll&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;registerFallbackValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NoParams&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

  &lt;span class="n"&gt;blocTest&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TodosState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'emits [Loading, Loaded] when the use case succeeds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;build:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
          &lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="s"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="s"&gt;'Ship it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;isCompleted:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TodosCubit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getTodos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nl"&gt;act:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nl"&gt;expect:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosLoading&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TodosLoaded&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="s"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="s"&gt;'Ship it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;isCompleted:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test runs in milliseconds and never touches a real API. I can write the failure-path test the same way by returning &lt;code&gt;Left(NetworkFailure())&lt;/code&gt;. Testability stops being a chore because the architecture made the dependencies fake-able by default. The same property is what lets these projects grow: a new feature is a new folder with the same four shapes, not a new way of doing things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Clean architecture in Flutter isn't about ceremony — it's about putting &lt;strong&gt;boundaries&lt;/strong&gt; where change happens: entities and use cases that never import infrastructure, a data layer that quarantines every API quirk behind a mapper, and a Cubit that only knows the domain. Start with one feature folder, resist letting Dio or JSON leak inward, and the structure compounds: faster tests, swappable backends, and parallel teamwork.&lt;/p&gt;

&lt;p&gt;If you're untangling a Flutter codebase or want a second pair of eyes on your BLoC layering, &lt;a href="https://bimalkhatri.com.np/#contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; — and if you're staffing a project that needs this done right from the start, you can &lt;a href="https://bimalkhatri.com.np/hire" rel="noopener noreferrer"&gt;hire me&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>bloc</category>
      <category>flutter</category>
    </item>
    <item>
      <title>Apple Pay in Flutter: The Easiest Implementation</title>
      <dc:creator>Gulshan Yadav</dc:creator>
      <pubDate>Sun, 16 Aug 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/mryadavgulshan/apple-pay-in-flutter-the-easiest-implementation-17he</link>
      <guid>https://dev.to/mryadavgulshan/apple-pay-in-flutter-the-easiest-implementation-17he</guid>
      <description>&lt;p&gt;So, in this article, I will be showing you how you can integrate Apple Pay into your Flutter app — and yes, this one is genuinely the easiest of the mobile wallets, because Apple has wrapped the entire flow into a native payment sheet. No card form, no bank list, no OTP. The user double-clicks the side button, confirms with Face ID, and the payment token is out.&lt;/p&gt;

&lt;p&gt;The reason Apple Pay integration is easy is that Apple does almost everything for you: the card vault, the biometrics, the tokenization, the UI. What is &lt;em&gt;not&lt;/em&gt; easy — and what stops most people for a full day — is the Apple Developer setup on the way in. This article covers both: the configuration you have to get right before Flutter, and the minimal Dart you need after.&lt;/p&gt;

&lt;p&gt;Let's jump into the coding part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the Dependencies
&lt;/h2&gt;

&lt;p&gt;For this purpose, we need to add this dependency in your &lt;code&gt;pubspec.yaml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter&lt;/span&gt;
  &lt;span class="na"&gt;pay_ios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pay_ios&lt;/code&gt; is Apple's official Flutter plugin for Apple Pay (part of the flutter-pay-plugins). It wraps PassKit's &lt;code&gt;PKPaymentAuthorizationViewController&lt;/code&gt;, so you never touch Swift. That single package is the whole dependency story — one line, no extra UI packages, no separate button package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Apple Developer Setup (The Part Everyone Misses)
&lt;/h2&gt;

&lt;p&gt;Before you write a single line of Dart, three things must exist on the Apple side, or your payment sheet will silently refuse to show:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Merchant ID.&lt;/strong&gt; In the Apple Developer portal, go to Certificates, Identifiers &amp;amp; Profiles → Identifiers, create a Merchant ID like &lt;code&gt;merchant.com.yourcompany.yourname&lt;/code&gt;. This is the ID your code references, and it must be enabled for your App ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Apple Pay capability.&lt;/strong&gt; In Xcode, open the Runner target → Signing &amp;amp; Capabilities → add "Apple Pay" and select your merchant ID. If you use &lt;code&gt;flutter build&lt;/code&gt;, verify this in the generated Xcode project before building the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Payment Processing Certificate.&lt;/strong&gt; In the Merchant ID settings, create a merchant identity certificate. Apple uses this to encrypt the payment token. If you use a payment provider (Stripe, Adyen, Braintree), they generate this certificate for you; otherwise you create a CSR from Apple.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The classic failure: everything works in code, and the payment sheet says "Apple Pay is not available." Nine times out of ten it is the entitlement or the merchant ID mismatch, not your Dart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The Minimal Dart Implementation
&lt;/h2&gt;

&lt;p&gt;Here is the entire Flutter side. Create a checkout page and present the Apple Pay sheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/material.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:pay_ios/pay_ios.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutPage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatefulWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_CheckoutPageState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_CheckoutPageState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ApplePayClient&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ApplePayClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;paymentConfiguration:&lt;/span&gt; &lt;span class="n"&gt;PaymentConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJsonString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;'''
        {
          "merchantId": "merchant.com.yourcompany.yourname",
          "merchantName": "Your App Name",
          "countryCode": "US",
          "currencyCode": "USD"
        }
        '''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_payWithApplePay&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;presentApplePay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;displayItems:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;label:&lt;/span&gt; &lt;span class="s"&gt;'Premium Plan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;amount:&lt;/span&gt; &lt;span class="s"&gt;'9.99'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;ApplePayItemType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;final_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nl"&gt;merchantCapabilities:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;MerchantCapability&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threeDSecure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nl"&gt;supportedNetworks:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayCardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;visa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayCardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mastercard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayCardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayCardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;discover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nl"&gt;requiredBillingContactFields:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayContactField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postalAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nl"&gt;requiredShippingContactFields:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayContactField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;ApplePayContactField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ApplePayResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Send result.token to YOUR backend for verification.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_verifyOnServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ApplePayResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Apple Pay error: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;appBar:&lt;/span&gt; &lt;span class="n"&gt;AppBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Checkout'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="n"&gt;Center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;ElevatedButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="n"&gt;_payWithApplePay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Pay with Apple Pay'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole implementation. &lt;code&gt;presentApplePay&lt;/code&gt; launches the native sheet, the user confirms with Face ID or Touch ID, and you receive an &lt;code&gt;ApplePayResult&lt;/code&gt; with the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Verify the Token on Your Backend
&lt;/h2&gt;

&lt;p&gt;Same rule as every wallet integration, and it is non-negotiable: &lt;strong&gt;the device gives you a token, not money.&lt;/strong&gt; Send it to your backend, and let your backend decrypt and charge it through your payment provider. On the backend, with Stripe it looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST /api/verify-apple-pay&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applePayToken&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm success from the gateway's response (or its webhook), never from the app. The app can always lie; the gateway cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Flow in Plain English
&lt;/h2&gt;

&lt;p&gt;Here is the sequence end to end, so you know what happens between the double-click and the money landing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user taps your Pay button.&lt;/li&gt;
&lt;li&gt;Your app calls &lt;code&gt;presentApplePay&lt;/code&gt;, and iOS builds the payment sheet natively — the card, the merchant name, the price, the shipping and contact fields you requested.&lt;/li&gt;
&lt;li&gt;The user authenticates with Face ID, Touch ID, or their device passcode.&lt;/li&gt;
&lt;li&gt;Apple wraps the selected card into an encrypted, single-use token and returns it to your app.&lt;/li&gt;
&lt;li&gt;Your app sends the token to your backend.&lt;/li&gt;
&lt;li&gt;Your backend forwards it to your payment provider.&lt;/li&gt;
&lt;li&gt;The provider decrypts the token (using the payment processing certificate), charges the card, and confirms — plus fires a webhook.&lt;/li&gt;
&lt;li&gt;Your backend marks the order paid and tells the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 5 through 8 are where the actual money moves, and they must never be skipped. If steps 3 and 4 work but step 6 never happens, the user believes they paid while you hold nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Line Items Properly
&lt;/h2&gt;

&lt;p&gt;A small detail that trips people: Apple Pay wants every item you show in the sheet to match what you eventually charge. If you show &lt;code&gt;'9.99'&lt;/code&gt; but your backend computes tax and charges &lt;code&gt;10.74&lt;/code&gt;, the user approved a different number than the one they paid. The clean pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the full order server-side — items, shipping, tax — before presenting the sheet.&lt;/li&gt;
&lt;li&gt;Pass the computed total as your &lt;code&gt;ApplePayItem&lt;/code&gt; amount.&lt;/li&gt;
&lt;li&gt;Send the same order reference to your backend when you forward the token, so the charge uses exactly what was quoted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you cannot finalize the amount upfront (variable shipping, tips), use &lt;code&gt;ApplePayItemType.pending&lt;/code&gt;, then update the charge server-side to the final value. Keeping the quoted number and the charged number identical avoids a whole category of refund requests and chargeback disputes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative Approaches
&lt;/h2&gt;

&lt;p&gt;Three variations are worth knowing before you commit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Button-only, without the manual client.&lt;/strong&gt; If your checkout is a fixed set of items, some payment providers' Flutter SDKs (Stripe, Adyen) expose a one-call Apple Pay button that handles the sheet and the provider confirmation together. Less code, but it ties you to that provider's SDK for both the UI and the charging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe/Adyen Flutter SDK with Apple Pay.&lt;/strong&gt; Rather than managing the token and the backend forward yourself, these SDKs accept your merchant configuration and return a confirmed &lt;code&gt;PaymentMethod&lt;/code&gt; you then charge server-side. If you already use one of those providers, this is usually the pragmatic choice — fewer moving parts, one vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual PKPaymentRequest via a channel.&lt;/strong&gt; You can write the PassKit integration yourself in Swift and call it over a MethodChannel. This gives you total control but also total responsibility — entitlement handling, the full authorization delegate, error mapping. The &lt;code&gt;pay_ios&lt;/code&gt; plugin exists precisely so you do not have to do this.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Handling the Failure Paths
&lt;/h2&gt;

&lt;p&gt;The sheet succeeds or cancels, and both are real user behavior you must render. Handle them explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;presentApplePay&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ApplePayResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_verifyOnServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// confirmed server-side&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ApplePayResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment failed. Please try again.'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subtle trap: users cancel the sheet by swiping it away or locking the phone — a surprising share of "failed" payments are actually cancels. Handle cancel distinctly, and never show an error screen for a cancel. And never treat the app-level result as proof of charge; only the provider's confirmation counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Important Notes &amp;amp; Pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Real device required.&lt;/strong&gt; Apple Pay does not work in the iOS Simulator for card payments. Test on a physical iPhone with a sandbox card. This is the #1 thing that makes people think their code is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox cards.&lt;/strong&gt; Add a test card (e.g., &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;) in Settings → Wallet on your device. It passes the full flow with no real charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merchant ID must match everywhere.&lt;/strong&gt; The ID in your code, the ID in Xcode's entitlement, and the ID in Apple's portal must be identical. A single typo and the sheet never appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Region availability.&lt;/strong&gt; Apple Pay only works in supported countries, and your app's region settings matter. An App Store region without Apple Pay will fail gracefully — handle it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;amount&lt;/code&gt; is a string with the decimal point.&lt;/strong&gt; &lt;code&gt;'9.99'&lt;/code&gt;, not &lt;code&gt;9.99&lt;/code&gt;. Keep the string format consistent with your currency rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token is single-use and short-lived.&lt;/strong&gt; Do not cache or log it. Forward it to your backend immediately and only once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ApplePayItemType.final_&lt;/code&gt; vs &lt;code&gt;pending&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;final_&lt;/code&gt; for a fixed charge. Use &lt;code&gt;pending&lt;/code&gt; if you'll finalize the amount later (e.g., tips or variable delivery), then update it server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle the &lt;code&gt;canceled&lt;/code&gt; case explicitly.&lt;/strong&gt; Users abort the sheet all the time; your UI should return to a clean state, not show a spinner forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Testing Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship, run through this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Sandbox card completes the full flow on a physical device&lt;/li&gt;
&lt;li&gt;[ ] Cancel returns the user to a clean state&lt;/li&gt;
&lt;li&gt;[ ] Token reaches the backend and the provider confirms the charge&lt;/li&gt;
&lt;li&gt;[ ] Webhook updates order status server-side&lt;/li&gt;
&lt;li&gt;[ ] Merchant ID is identical in code, entitlement, and portal&lt;/li&gt;
&lt;li&gt;[ ] Fallback payment method exists for regions/unsupported devices&lt;/li&gt;
&lt;li&gt;[ ] No secrets or merchant IDs committed to the repo&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;pay_ios&lt;/code&gt; maintained?&lt;/strong&gt; Yes — it is part of the official Flutter pay plugins maintained by Apple, alongside &lt;code&gt;pay_android&lt;/code&gt; for Google Pay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need my own payment processing infrastructure?&lt;/strong&gt; No. You need a payment provider agreement (Stripe, Adyen, Braintree) to decrypt the token. Apple Pay itself has no integration fee; you pay your provider's processing fee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the same backend as Google Pay?&lt;/strong&gt; Mostly, yes — both give you a token your backend forwards to the same gateway. That is why a single checkout backend can serve both wallets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the sheet show "Apple Pay is not available"?&lt;/strong&gt; Almost always one of: no entitlement in the Xcode target, a merchant ID mismatch between the code and the portal, a payment processing certificate missing, or running on the Simulator. Walk the Step 1 checklist in order — it is a configuration problem, not a code problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a development device enough for testing?&lt;/strong&gt; Yes. A physical iPhone with a sandbox card exercises the entire flow. Just remember the sandbox card only works in sandbox mode with the test entitlement — keep that pairing consistent in your build config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Apple Pay work in every country?&lt;/strong&gt; No. It is available in a growing list of countries, and availability is tied to both the App Store region and the card issuer. Test on a device configured for a supported region, and provide a fallback for everything else.&lt;/p&gt;

&lt;p&gt;That's it — a complete Apple Pay integration in Flutter. The Dart is genuinely this short; the configuration is where you will lose time, and I hope the setup section here saves you that day.&lt;/p&gt;

&lt;p&gt;I have also written integrations for Google Pay, PayPal, and UPI/Razorpay — comment below with your payment gateway and I'll cover it next.&lt;/p&gt;




&lt;p&gt;*Gulshan Yad&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
    <item>
      <title>One-Shot UI Side Effects in BlocSignal: Snackbars, Dialogs, and Navigation Without State Pollution</title>
      <dc:creator>Randal L. Schwartz</dc:creator>
      <pubDate>Sat, 15 Aug 2026 18:45:56 +0000</pubDate>
      <link>https://dev.to/gde/one-shot-ui-side-effects-in-blocsignal-snackbars-dialogs-and-navigation-without-state-pollution-52j4</link>
      <guid>https://dev.to/gde/one-shot-ui-side-effects-in-blocsignal-snackbars-dialogs-and-navigation-without-state-pollution-52j4</guid>
      <description>&lt;p&gt;Every Flutter developer has run into the &lt;strong&gt;Sticky State Dilemma&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You build a login screen. When authentication fails, your state container emits an error. You catch it in your UI and show a &lt;code&gt;SnackBar&lt;/code&gt;. Everything works—until the user rotates their phone, pulls down the notification shade, or types on the virtual keyboard.&lt;/p&gt;

&lt;p&gt;Suddenly, the widget tree rebuilds. The state container is still holding &lt;code&gt;AuthErrorState("Invalid password")&lt;/code&gt;. The UI listener fires again. And a duplicate snackbar appears out of nowhere.&lt;/p&gt;

&lt;p&gt;In this article, we’ll explore why domain state machines struggle with transient UI events, how the classic BLoC community worked around this with &lt;code&gt;package:bloc_presentation&lt;/code&gt;, and how &lt;strong&gt;&lt;code&gt;BlocSignal&lt;/code&gt;&lt;/strong&gt; lets you handle one-shot side effects cleanly with &lt;strong&gt;zero additional package dependencies&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Root Problem: Persistent State vs. Ephemeral Actions
&lt;/h2&gt;

&lt;p&gt;State management in Flutter is designed to model &lt;strong&gt;persistent truth&lt;/strong&gt; over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Is the user logged in?&lt;/em&gt; &lt;code&gt;AuthState.authenticated(user)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Is data loading?&lt;/em&gt; &lt;code&gt;TodoState.loading&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;What is the cart total?&lt;/em&gt; &lt;code&gt;$49.99&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Persistent state answers: &lt;strong&gt;"What is the system's current condition?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In contrast, UI presentation actions are &lt;strong&gt;ephemeral pulses&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Show a brief SnackBar toast.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Pop up an alert confirmation dialog.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Push a new route on the Navigator stack.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Vibrate the haptic motor.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These actions answer: &lt;strong&gt;"What just happened that requires a one-time reaction?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌────────────────────────────────────────────────────────┐
 │                   State vs. Effects                    │
 ├────────────────────────────┬───────────────────────────┤
 │ Persistent State           │ Ephemeral Side-Effect     │
 ├────────────────────────────┼───────────────────────────┤
 │ • Survived by UI rebuilds  │ • Consumed once &amp;amp; gone    │
 │ • Represented in signals   │ • Triggered by an event   │
 │ • Backed by equality diffs │ • Zero domain state footprint │
 └────────────────────────────┴───────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. The Legacy Workarounds (And Their Hidden Costs)
&lt;/h2&gt;

&lt;p&gt;Historically in &lt;code&gt;package:bloc&lt;/code&gt; and &lt;code&gt;package:flutter_bloc&lt;/code&gt;, developers used one of three approaches:&lt;/p&gt;

&lt;h3&gt;
  
  
  Workaround A: The "Reset State" Ping-Pong
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Emitting a reset state immediately after error&lt;/span&gt;
&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthInitial&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// Extra emission, extra microtask, extra rebuild!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Downside:&lt;/em&gt; Causes two separate microtask queue ticks and multiple widget rebuild cycles just to reset a transient flag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workaround B: "Consumed" Wrapper Flags
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;errorSnackbarMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;hasShownSnackbar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Manual bookkeeping everywhere!&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Downside:&lt;/em&gt; Clutters state classes with imperative UI tracking flags that violate domain purity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workaround C: &lt;code&gt;package:bloc_presentation&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;LeanCode created &lt;a href="https://pub.dev/packages/bloc_presentation" rel="noopener noreferrer"&gt;&lt;code&gt;package:bloc_presentation&lt;/code&gt;&lt;/a&gt;, adding a separate secondary &lt;code&gt;StreamController.broadcast()&lt;/code&gt; to Blocs so developers could call &lt;code&gt;emitPresentation(MyEvent())&lt;/code&gt; independently of &lt;code&gt;emit(state)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While &lt;code&gt;bloc_presentation&lt;/code&gt; solved the problem well for classic BLoC, maintaining third-party wrapper packages in your monorepo introduces versioning churn, boilerplate, and dependency overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The &lt;code&gt;BlocSignal&lt;/code&gt; Advantage: 0ms Synchronous Guarantees
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://pub.dev/packages/bloc_signals" rel="noopener noreferrer"&gt;&lt;code&gt;BlocSignal&lt;/code&gt;&lt;/a&gt;, state propagation is &lt;strong&gt;synchronous&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Unlike classic BLoC which queues updates asynchronously on microtask-queue Streams, calling &lt;code&gt;emit(newState)&lt;/code&gt; in &lt;code&gt;BlocSignal&lt;/code&gt; updates the underlying reactive signal and settles dependencies &lt;strong&gt;immediately in the exact same frame&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because state updates are synchronous, you often don't need any presentation streams at all!&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Direct Async UI Handlers (Recommended)
&lt;/h3&gt;

&lt;p&gt;When an action is initiated by a user interaction (like tapping a button), the simplest and cleanest pattern is handling the reaction right in the button's &lt;code&gt;onPressed&lt;/code&gt; callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;ElevatedButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;cubit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AuthCubit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. Await domain logic completion&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emailController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwordController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Safe async context guard&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Inspect settled state synchronously with Dart pattern matching&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stateValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;AuthSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;Navigator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pushReplacementNamed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;AuthFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;_:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Log In'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this works so well in &lt;code&gt;BlocSignal&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Race Conditions:&lt;/strong&gt; The moment &lt;code&gt;cubit.signIn(...)&lt;/code&gt; finishes, &lt;code&gt;cubit.stateValue&lt;/code&gt; is 100% up to date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Duplicate Triggers:&lt;/strong&gt; Screen rotations or unrelated rebuilds will never re-execute the button handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Extra Code:&lt;/strong&gt; No special listeners, no extra streams, no consumable wrapper classes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. The Zero-Dependency &lt;code&gt;PresentationMixin&lt;/code&gt; Recipe
&lt;/h2&gt;

&lt;p&gt;What if your domain state machine triggers side-effects autonomously (for example, an incoming WebSocket disconnects, a background sync finishes, or you are migrating an existing codebase from &lt;code&gt;bloc_presentation&lt;/code&gt;)?&lt;/p&gt;

&lt;p&gt;You can drop in a &lt;strong&gt;100% compatible presentation architecture in ~25 lines of pure Dart&lt;/strong&gt; without adding any 3rd-party dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The Mixin (&lt;code&gt;BlocSignalPresentationMixin&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:async'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals/bloc_signals.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;/// Mixin that adds one-shot presentation event broadcasting to any [BlocSignalBase].&lt;/span&gt;
&lt;span class="kd"&gt;mixin&lt;/span&gt; &lt;span class="nc"&gt;BlocSignalPresentationMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;BlocSignalBase&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_presentationController&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamController&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;/// Stream of one-shot presentation events.&lt;/span&gt;
  &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;presentationStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_presentationController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// Dispatches a one-shot presentation event to active UI listeners.&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;emitPresentation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_presentationController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_presentationController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: The Flutter Listener (&lt;code&gt;BlocSignalPresentationListener&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:async'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/widgets.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals_flutter/bloc_signals_flutter.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;/// Listens to one-shot presentation events from a [BlocSignalBase] with [BlocSignalPresentationMixin].&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BlocSignalPresentationListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;
        &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;BlocSignalPresentationMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatefulWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BlocSignalPresentationListener&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bloc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;bloc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlocSignalPresentationListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;_BlocSignalPresentationListenerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_BlocSignalPresentationListenerState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;
        &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;BlocSignalPresentationMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlocSignalPresentationListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;StreamSubscription&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_resolvedBloc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;didChangeDependencies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;didChangeDependencies&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;bloc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bloc&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_resolvedBloc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;bloc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_unsubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="n"&gt;_resolvedBloc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bloc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;_subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_resolvedBloc&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;presentationStream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_unsubscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_subscription&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;_subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_unsubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;child&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SizedBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shrink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Putting It Together: A Real-World Example
&lt;/h2&gt;

&lt;p&gt;Let's see how clean our Cubit and UI look when composed together:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Events &amp;amp; Cubit Definition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:bloc_signals/bloc_signals.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Define one-shot presentation events as a sealed hierarchy&lt;/span&gt;
&lt;span class="kd"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutPresentationEvent&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShowErrorToast&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CheckoutPresentationEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ShowErrorToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LaunchPaymentGateway&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CheckoutPresentationEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;LaunchPaymentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Attach the mixin to your Cubit or Bloc&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CubitSignal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;BlocSignalPresentationMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutPresentationEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CheckoutState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;CheckoutCubit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;initialState:&lt;/span&gt; &lt;span class="n"&gt;CheckoutInitial&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processPayment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckoutProcessing&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;paymentApi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createInvoice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckoutSuccess&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

      &lt;span class="c1"&gt;// Emit one-shot navigation/payment launch event&lt;/span&gt;
      &lt;span class="n"&gt;emitPresentation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LaunchPaymentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckoutFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

      &lt;span class="c1"&gt;// Emit transient error toast event&lt;/span&gt;
      &lt;span class="n"&gt;emitPresentation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShowErrorToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Payment failed: &lt;/span&gt;&lt;span class="si"&gt;${e.toString()}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Flutter UI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutView&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatelessWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CheckoutView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BlocSignalPresentationListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutCubit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CheckoutPresentationEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;listener:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;ShowErrorToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nl"&gt;backgroundColor:&lt;/span&gt; &lt;span class="n"&gt;Colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;redAccent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LaunchPaymentGateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;launchUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;appBar:&lt;/span&gt; &lt;span class="n"&gt;AppBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Checkout'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CheckoutBody&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. Migration Comparison Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Legacy &lt;code&gt;bloc_presentation&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;BlocSignal&lt;/code&gt; Presentation Recipe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;External Dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;package:bloc_presentation&lt;/code&gt; + &lt;code&gt;nested&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 external dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stream-based side channel&lt;/td&gt;
&lt;td&gt;Stream-based side channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Emission Syntax&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;emitPresentation(event)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;emitPresentation(event)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Listener Widget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BlocPresentationListener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BlocSignalPresentationListener&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Automatic Cleanup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual stream closing&lt;/td&gt;
&lt;td&gt;Managed in &lt;code&gt;close()&lt;/code&gt; lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Reactivity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Microtask Streams&lt;/td&gt;
&lt;td&gt;Synchronous Signals (0ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Handling one-shot side-effects shouldn't require complex state hacks or heavy external packages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;For user interactions&lt;/strong&gt;: Use &lt;strong&gt;Direct Async UI Handlers&lt;/strong&gt; (&lt;code&gt;await cubit.action()&lt;/code&gt;)—&lt;code&gt;BlocSignal&lt;/code&gt;'s synchronous emissions make this glitch-free and safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For asynchronous domain broadcasts&lt;/strong&gt;: Use the 25-line &lt;strong&gt;&lt;code&gt;BlocSignalPresentationMixin&lt;/code&gt;&lt;/strong&gt; recipe for 100% &lt;code&gt;bloc_presentation&lt;/code&gt; parity with zero dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep domain state clean&lt;/strong&gt;: Keep persistent data in &lt;code&gt;stateValue&lt;/code&gt; and ephemeral UI triggers in presentation streams.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Resources &amp;amp; Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://pub.dev/packages/bloc_signals" rel="noopener noreferrer"&gt;&lt;code&gt;bloc_signals&lt;/code&gt; on pub.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 Official Website: &lt;a href="https://blocsignal.dev" rel="noopener noreferrer"&gt;blocsignal.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 GitHub Repository: &lt;a href="https://github.com/RandalSchwartz/BlocSignal" rel="noopener noreferrer"&gt;RandalSchwartz/BlocSignal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>statemanagement</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google Pay Integration in Flutter with One Button</title>
      <dc:creator>Gulshan Yadav</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/mryadavgulshan/google-pay-integration-in-flutter-with-one-button-1kj9</link>
      <guid>https://dev.to/mryadavgulshan/google-pay-integration-in-flutter-with-one-button-1kj9</guid>
      <description>&lt;p&gt;So, in this article, I will be showing you how you can add Google Pay to your Flutter app with literally one button. No card-number form, no CVV field, no "add a payment method" screen. The user taps the Google Pay button, picks a card that Google Pay already knows, confirms with their fingerprint, and the payment data is on its way.&lt;/p&gt;

&lt;p&gt;I keep getting asked about payments in Flutter, and after PayPal and Stripe, Google Pay is the one people want next. The good news: it is the easiest of the three, because Google has done the hard work. In this article I'll show you the exact dependencies, the button, the flow, and the pitfalls I hit when I first shipped it — so you don't lose a day to the same setup trap.&lt;/p&gt;

&lt;p&gt;Let's jump into the coding part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the Dependencies
&lt;/h2&gt;

&lt;p&gt;For this purpose, we need to add these dependencies in your &lt;code&gt;pubspec.yaml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter&lt;/span&gt;
  &lt;span class="na"&gt;pay_android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.0.0&lt;/span&gt;
  &lt;span class="na"&gt;google_pay_button&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^0.1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pay_android&lt;/code&gt; is the official Google-maintained Flutter plugin for Google Pay. It wraps the native Android Google Pay API, so you don't need to write Kotlin.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;google_pay_button&lt;/code&gt; renders the actual branded Google Pay button, so you comply with Google's button guidelines without hand-drawing the G logo.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;If you only want the button and prefer to write the payment logic yourself, skip &lt;code&gt;google_pay_button&lt;/code&gt;. But if you want the one-tap experience described above, take both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: Configure Android
&lt;/h2&gt;

&lt;p&gt;Before any Dart runs, the Android side needs two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your app's &lt;code&gt;minSdkVersion&lt;/code&gt; must be 21 or higher (in &lt;code&gt;android/app/build.gradle&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Add the Google Pay meta-data to your &lt;code&gt;AndroidManifest.xml&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.gms.wallet.api.enabled"&lt;/span&gt;
    &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add your payment configuration as an asset. This is a JSON file that tells Google Pay which networks, auth methods, and gateway to use. Create &lt;code&gt;assets/google_pay.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google_pay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"merchantName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your App Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"merchantId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BCR2DN4TXXXXXXXXXX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowedCardNetworks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"VISA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MASTERCARD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AMEX"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowedAuthMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PAN_ONLY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CRYPTOGRAM_3DS"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gateway"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gateway"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gatewayMerchantId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pk_test_..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few important notes on this file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;environment: "TEST"&lt;/code&gt; means you can run the whole flow with test cards and no real money moves. Switch it to &lt;code&gt;"PRODUCTION"&lt;/code&gt; only for release builds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;merchantId&lt;/code&gt; is your Google Pay merchant ID from the Google Pay &amp;amp; Wallet Console. You can leave it empty in TEST, but production requires it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allowedAuthMethods&lt;/code&gt;: &lt;code&gt;PAN_ONLY&lt;/code&gt; is the card on file, &lt;code&gt;CRYPTOGRAM_3DS&lt;/code&gt; is the encrypted token. Using both maximizes the number of users who can pay.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;gateway&lt;/code&gt; section tells Google which payment processor will decrypt the token — Stripe, Adyen, Braintree, etc. You need a live account with that gateway before production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Register the asset in &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;assets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;assets/google_pay.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: The One Button
&lt;/h2&gt;

&lt;p&gt;Here is the whole Flutter side. Create a checkout page and drop the button in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/material.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:google_pay_button/google_pay_button.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:pay_android/pay_android.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutPage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatefulWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_CheckoutPageState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_CheckoutPageState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckoutPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;PaymentConfiguration&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;PaymentConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'assets/google_pay.json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_onGooglePayPressed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Confirm the device can actually pay.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PaymentsClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;environment:&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;isReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isReadyToPay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;paymentConfiguration:&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;allowedCardNetworks:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;visa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CardNetwork&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mastercard&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nl"&gt;allowedAuthMethods:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AuthMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cryptogram3ds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AuthMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;panOnly&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Present the Google Pay sheet.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;presentPaymentSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;merchantName:&lt;/span&gt; &lt;span class="s"&gt;'Your App Name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;paymentConfiguration:&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;paymentItems:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;PaymentItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;label:&lt;/span&gt; &lt;span class="s"&gt;'Premium Plan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;amount:&lt;/span&gt; &lt;span class="s"&gt;'9.99'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;status:&lt;/span&gt; &lt;span class="n"&gt;PaymentItemStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;finalPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Handle the result.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Send result.token to YOUR backend for verification.&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_verifyPaymentOnServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;paymentData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;appBar:&lt;/span&gt; &lt;span class="n"&gt;AppBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Checkout'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="n"&gt;Center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;GooglePayButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;paymentConfiguration:&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="n"&gt;_onGooglePayPressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;GooglePayButtonType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That is the entire integration. The &lt;code&gt;GooglePayButton&lt;/code&gt; widget handles the tap; &lt;code&gt;presentPaymentSheet&lt;/code&gt; opens Google's native sheet where the user picks a card and confirms with biometrics; and you get a &lt;code&gt;PaymentResult&lt;/code&gt; to react to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Verify the Token on Your Backend
&lt;/h2&gt;

&lt;p&gt;Here is the part most beginners get wrong, and it matters: &lt;strong&gt;the Google Pay token is not money collected.&lt;/strong&gt; It is a one-time encrypted payment credential. Your app must send it to your own backend, and your backend must pass it to your payment gateway (Stripe, Adyen, etc.) which decrypts it and actually charges the card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flutter app ──▶ Your backend ──▶ Payment gateway (decrypt + charge)
                    ▲
                    └── webhook: payment.succeeded ──▶ mark order paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never trust the app to tell you the payment succeeded. Always confirm the charge server-side, ideally via the gateway's webhook. On the backend, for Stripe it looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST /api/verify-payment&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;googlePayToken&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confirmation_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different gateways have different calls, but the pattern is identical: forward the token, wait for the gateway to confirm, then mark the order paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Flow in Plain English
&lt;/h2&gt;

&lt;p&gt;Here is the whole sequence, so you know exactly what happens between the tap and the charge landing in your account:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user taps the Google Pay button.&lt;/li&gt;
&lt;li&gt;Your app calls &lt;code&gt;isReadyToPay&lt;/code&gt; to confirm the device has Google Pay set up with an eligible card.&lt;/li&gt;
&lt;li&gt;Your app calls &lt;code&gt;presentPaymentSheet&lt;/code&gt;, and Google's native sheet opens — the card list, the price, the pay button.&lt;/li&gt;
&lt;li&gt;The user authenticates (fingerprint, face unlock, or PIN) and approves.&lt;/li&gt;
&lt;li&gt;Google returns an encrypted, single-use payment token to your app.&lt;/li&gt;
&lt;li&gt;Your app sends the token to your backend.&lt;/li&gt;
&lt;li&gt;Your backend forwards it to the payment gateway.&lt;/li&gt;
&lt;li&gt;The gateway decrypts the token, charges the card, and returns a confirmation (and fires a webhook).&lt;/li&gt;
&lt;li&gt;Your backend marks the order paid and tells the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 6 through 9 are the ones you must never skip. If steps 4 and 5 work but step 7 never happens, the user believes they paid and you never receive the money. That is the exact failure mode that looks like a successful integration in the demo and collapses in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative Approaches
&lt;/h2&gt;

&lt;p&gt;Two variations are worth knowing before you commit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Button-only, without the manual &lt;code&gt;PaymentsClient&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;GooglePayButton&lt;/code&gt; widget can present the sheet and return the result through its own &lt;code&gt;onPaymentResult&lt;/code&gt; callback, skipping the explicit &lt;code&gt;isReadyToPay&lt;/code&gt; check and the manual &lt;code&gt;presentPaymentSheet&lt;/code&gt; call. It is less code, but you lose fine-grained control — per-run payment items and explicit readiness handling. For a single fixed product price, the button-only path is fine; for dynamic carts, keep the explicit client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebView fallback with Google Pay's JavaScript API.&lt;/strong&gt; You can render the Google Pay web experience inside a &lt;code&gt;webview_flutter&lt;/code&gt; and bridge the token back to Dart. This is useful when your backend already runs the web checkout and you want to reuse the same flow on mobile. The cost: no native sheet, a JavaScript bridge you own, and message-passing and error handling that live in your code. I have used this for a legacy app with an existing web checkout. For a new build, I would take the native plugin every time.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Handling the Failure Paths
&lt;/h2&gt;

&lt;p&gt;The happy path is one line of code; the failure paths are where payments are actually won or lost. Cover at least these cases in your UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;presentPaymentSheet&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_verifyPaymentOnServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;paymentData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canceled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// User closed the sheet — return them to a clean cart state.&lt;/span&gt;
  &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Any other failure — do NOT silently stay on a spinner.&lt;/span&gt;
  &lt;span class="n"&gt;ScaffoldMessenger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showSnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SnackBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Payment failed. Please try again.'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never leave the button in a loading state after a failure, and never show success based on the app's result alone — success must be confirmed by your backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Important Notes &amp;amp; Pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never hardcode &lt;code&gt;Environment.test&lt;/code&gt; in release.&lt;/strong&gt; Read the environment from a config flag, not from editing the file per build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token is single-use.&lt;/strong&gt; Do not cache it, do not log it to console, do not display it. One charge attempt per token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Pay is Android-only.&lt;/strong&gt; This plugin does nothing on iOS. For iOS you integrate Apple Pay separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requires Google Play Services.&lt;/strong&gt; Test on an emulator with Play Services enabled, or on a real device. On a bare emulator the button will simply not work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PaymentItem&lt;/code&gt; amounts are strings, not numbers.&lt;/strong&gt; &lt;code&gt;'9.99'&lt;/code&gt;, not &lt;code&gt;9.99&lt;/code&gt;. Round to the currency's smallest unit display — a missing decimal place has shipped real-world pricing bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with real test cards.&lt;/strong&gt; In TEST mode use your gateway's test cards (e.g., Stripe's &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;). The flow is 100% real except no money moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle &lt;code&gt;isReadyToPay == false&lt;/code&gt;.&lt;/strong&gt; Some devices/browsers won't support Google Pay. Show a fallback payment method instead of a dead button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't forget the gateway agreement.&lt;/strong&gt; The token is encrypted to a gateway. You must have a live account with the gateway listed in your &lt;code&gt;google_pay.json&lt;/code&gt; or production decryption fails with a confusing error.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Testing Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship, walk through this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] TEST environment payment with a test card completes end-to-end&lt;/li&gt;
&lt;li&gt;[ ] Cancellation flow returns the user to the app with a clear message&lt;/li&gt;
&lt;li&gt;[ ] Token reaches your backend and the gateway confirms the charge&lt;/li&gt;
&lt;li&gt;[ ] Webhook updates the order status server-side (not just the app)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;isReadyToPay == false&lt;/code&gt; falls back to another payment method&lt;/li&gt;
&lt;li&gt;[ ] PRODUCTION environment switch is config-driven, not manual&lt;/li&gt;
&lt;li&gt;[ ] No secret keys or merchant IDs committed to the repo&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;pay_android&lt;/code&gt; still maintained?&lt;/strong&gt; Yes. It is part of the official Flutter pay plugins maintained by Google, with active releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this with any backend language?&lt;/strong&gt; Yes. The Flutter side just sends the token to your backend; the backend can be Node, Python, Go, anything that talks to your gateway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Google Pay support recurring subscriptions?&lt;/strong&gt; Google Pay returns a single-payment token. For recurring billing, use that token to create a customer and a subscription in your gateway (Stripe, Razorpay, etc.) on your backend, then charge it on your own schedule. Do not expect Google Pay itself to manage recurring charges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did TEST work but production fails?&lt;/strong&gt; The three classic causes, in order: the environment is still &lt;code&gt;TEST&lt;/code&gt;, the merchant ID is missing or wrong, or the gateway listed in &lt;code&gt;google_pay.json&lt;/code&gt; does not match the gateway actually processing the token. The token is encrypted to a specific gateway, so a mismatch fails at decryption time with a confusing error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Google Pay cost money to integrate?&lt;/strong&gt; No integration fee from Google. You pay the standard processing fee to your payment gateway.&lt;/p&gt;

&lt;p&gt;That's it — a complete Google Pay integration in Flutter, with one button. The whole thing took me less than an hour once I had the &lt;code&gt;google_pay.json&lt;/code&gt; right, and I hope this saves you the setup trap I hit on my first attempt.&lt;/p&gt;

&lt;p&gt;I have also written integrations for PayPal, Stripe, and UPI/Razorpay — comment below with your payment gateway and I'll cover it next.&lt;/p&gt;




&lt;p&gt;*Gulshan Yad&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
    <item>
      <title>Flutter app development company: 7 questions that expose a partner in 2026</title>
      <dc:creator>Manu Shukla</dc:creator>
      <pubDate>Sat, 15 Aug 2026 10:56:35 +0000</pubDate>
      <link>https://dev.to/mr_manushukla/flutter-app-development-company-7-questions-that-expose-a-partner-in-2026-5fe4</link>
      <guid>https://dev.to/mr_manushukla/flutter-app-development-company-7-questions-that-expose-a-partner-in-2026-5fe4</guid>
      <description>&lt;h1&gt;
  
  
  Flutter app development company: 7 questions that expose a partner in 2026
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Summary.&lt;/strong&gt; Flutter 3.47 reached stable on 12 August 2026, alongside Dart 3.13, on a published cadence of four stable releases a year. That release made Impeller the default renderer on macOS, Windows and Linux, raised the iOS minimum from 13 to 15 and macOS from 10.15 to 12, and confirmed that the Material and Cupertino design libraries are scheduled for formal deprecation in the November 2026 stable release. Google Play requires new apps and updates to target API level 36 from 31 August 2026. Apple has required the iOS 26 SDK for App Store Connect uploads since 28 April 2026. Google reported over 1.5 million monthly Flutter developers at I/O 2026, up 50% year on year.&lt;/p&gt;

&lt;p&gt;None of that is exotic. All of it is public. And a Flutter partner who cannot walk you through it is going to hand you a codebase that needs three forced migrations in its first year.&lt;/p&gt;

&lt;p&gt;The useful way to evaluate a Flutter company in 2026 is not to look at their portfolio. It is to ask them what is about to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 1: what stable release are you on, and what is the cadence?
&lt;/h2&gt;

&lt;p&gt;The current stable is Flutter 3.47.0, listed at the top of the &lt;a href="https://docs.flutter.dev/release/release-notes" rel="noopener noreferrer"&gt;Flutter release notes&lt;/a&gt;, with 3.44.0, 3.41.0 and 3.38.0 behind it. Dart 3.13.0 shipped the same day; the &lt;a href="https://dart.dev/resources/whats-new" rel="noopener noreferrer"&gt;Dart what's new page&lt;/a&gt; records "Released on: August 12, 2026" and covers changes from 18 May 2026 onward. Its headline language feature is primary constructors, which we covered when it landed in &lt;a href="https://ecorpit.com/dart-3-12-primary-constructors-dot-shorthands-guide-2026/" rel="noopener noreferrer"&gt;Dart 3.12 primary constructors and dot shorthands&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The cadence is published a year ahead. Flutter's &lt;a href="https://docs.flutter.dev/install/archive" rel="noopener noreferrer"&gt;install archive&lt;/a&gt; lists the 2026 schedule as 3.41 in February, 3.44 in May, 3.47 in August and 3.50 in November, each with a branch cutoff roughly five weeks before release, and notes that roughly every third beta is promoted to stable.&lt;/p&gt;

&lt;p&gt;That matters commercially because it makes maintenance predictable and therefore quotable. A partner proposing an annual "upgrade project" is either padding or has not read the schedule. Four stable releases a year, absorbed continuously, is a small standing cost. Four skipped, absorbed at once, is the expensive version.&lt;/p&gt;

&lt;p&gt;Ask which release their last three delivered projects run on. If the answer is more than two stables behind, ask why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: what did you do about Impeller?
&lt;/h2&gt;

&lt;p&gt;Impeller has quietly finished replacing Skia almost everywhere. Flutter's &lt;a href="https://docs.flutter.dev/perf/impeller" rel="noopener noreferrer"&gt;Impeller documentation&lt;/a&gt; is unambiguous by platform.&lt;/p&gt;

&lt;p&gt;On iOS, "Impeller is the only supported rendering engine on iOS with no ability to switch to Skia." On Android, it "is available and enabled by default on Android API 29+", falling back to the legacy OpenGL renderer on lower versions or devices without Vulkan, with an opt-out still available through &lt;code&gt;--no-enable-impeller&lt;/code&gt; or the &lt;code&gt;io.flutter.embedding.android.EnableImpeller&lt;/code&gt; manifest key. On macOS, Windows and Linux the documentation now reads that Impeller "is available and enabled by default as of Flutter 3.47" and that "in a future release, the ability to opt out of using Impeller will be removed."&lt;/p&gt;

&lt;p&gt;Web is the exception. Flutter on the web still uses Skia and "might use Impeller in the future."&lt;/p&gt;

&lt;p&gt;A partner who shipped a desktop Flutter app before August 2026 has a renderer change to validate, particularly around custom shaders and platform views. One who does not know that is not tracking the engine. We wrote about this transition when it became mandatory on mobile in &lt;a href="https://ecorpit.com/impeller-mandatory-flutter-android-ios-2026/" rel="noopener noreferrer"&gt;Impeller becoming mandatory on Flutter Android and iOS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: are you off CocoaPods yet?
&lt;/h2&gt;

&lt;p&gt;This is the cleanest single test of whether a partner is current, because it has a public scoreboard.&lt;/p&gt;

&lt;p&gt;Swift Package Manager replaced CocoaPods as the default dependency manager for iOS and macOS Flutter apps in Flutter 3.44. The &lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-47" rel="noopener noreferrer"&gt;Flutter 3.47 release post&lt;/a&gt; states that "because CocoaPods is now in maintenance mode, plugins that do not migrate to SwiftPM will eventually stop working", that unmigrated plugins "also receive lower pub.dev scores", and that 92 of the top 100 iOS plugins have now migrated.&lt;/p&gt;

&lt;p&gt;The scoreboard is the useful part. If a partner's proposed plugin list includes packages still on CocoaPods, that is a visible, checkable risk in the estimate rather than an argument about quality. Our walkthrough of the mechanics is in &lt;a href="https://ecorpit.com/cocoapods-sunset-spm-migration-ios-flutter-2026/" rel="noopener noreferrer"&gt;the CocoaPods sunset and SwiftPM migration for iOS and Flutter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: what is your plan for Material and Cupertino leaving the SDK?
&lt;/h2&gt;

&lt;p&gt;This is the largest forced migration on the Flutter horizon and it is three months away.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-47" rel="noopener noreferrer"&gt;Flutter 3.47 release post&lt;/a&gt; states that "the original design libraries inside the core SDK are scheduled for formal deprecation in the upcoming Fall stable release in November." Flutter provides an automated path, &lt;code&gt;dart fix --apply --code=migrate_design_widgets&lt;/code&gt;, and the direction was set out in the &lt;a href="https://flutter.dev/blog/flutter-darts-2026-roadmap" rel="noopener noreferrer"&gt;2026 Flutter and Dart roadmap&lt;/a&gt;, which describes "decoupling the Material and Cupertino design systems into standalone packages" as part of moving to an open and sustainable operating model.&lt;/p&gt;

&lt;p&gt;Automated migrations handle the common cases and leave the interesting ones. Heavily themed apps, custom widget subclasses and anything that reaches into Material internals will need hands on it. A partner quoting a fixed-price build for delivery in Q4 2026 without a line for this migration is quoting a number they will come back to renegotiate. We set out the detail in &lt;a href="https://ecorpit.com/flutter-material-ui-cupertino-ui-package-migration-2026/" rel="noopener noreferrer"&gt;the Flutter Material and Cupertino UI package migration&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is a related survey signal worth knowing. Flutter's &lt;a href="https://flutter.dev/blog/flutter-q2-2026-survey" rel="noopener noreferrer"&gt;Q2 2026 developer survey&lt;/a&gt;, run 8 to 22 June 2026 with over 3,500 complete responses, reported 93% positive satisfaction overall and trust in Flutter rising from 77% to 83%, but recorded that "Cupertino widgets dropped 6 points to 61% top-2 box, marking the steepest decline anywhere in the survey." The iOS-fidelity story is the soft spot, and it is the one being restructured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 5: which OS floors did you just lose?
&lt;/h2&gt;

&lt;p&gt;Flutter 3.47 raised minimums, and that has product consequences before it has engineering ones.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-47" rel="noopener noreferrer"&gt;3.47 release post&lt;/a&gt; records the iOS minimum moving from 13 to 15 and macOS from 10.15 to 12, the latter explicitly to support Xcode 27. The current &lt;a href="https://docs.flutter.dev/reference/supported-platforms" rel="noopener noreferrer"&gt;supported platforms matrix&lt;/a&gt; gives Android 24 to 37 supported with 23 and earlier unsupported, iOS 15 to 26 supported with 14 and earlier unsupported, macOS Monterey through Tahoe, Windows 10 and 11, and web on the latest two versions of Chrome, Edge and Firefox plus Safari 15.6 and above.&lt;/p&gt;

&lt;p&gt;Android toolchain requirements moved with it. The 3.47 post pins Java 17 as the minimum, KGP 2.4.0, AGP 9.1.0 and Gradle 9.3.1, with &lt;code&gt;compileSdkVersion&lt;/code&gt; and &lt;code&gt;targetSdkVersion&lt;/code&gt; defaulting to API 36 and &lt;code&gt;minSdkVersion&lt;/code&gt; to API 24. The Kotlin plugin change is a hard build break, not a warning: Flutter's &lt;a href="https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin" rel="noopener noreferrer"&gt;built-in Kotlin migration guide&lt;/a&gt; states that "built-in Kotlin is the default in AGP 9 and later. Apps that use the kotlin-android plugin, also known as the Kotlin Gradle Plugin (KGP), will fail to build." Our migration notes are in &lt;a href="https://ecorpit.com/flutter-agp-9-kgp-migration-android-build-decision-2026/" rel="noopener noreferrer"&gt;the Flutter AGP 9 and KGP migration decision&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One more, driven by Apple rather than Flutter: the 3.47 post warns that "the iOS 27 SDK now mandates the UIScene lifecycle for all UIKit-based apps. Apps built with Xcode 27 that do not adopt UIScene will fail to launch on startup."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Migration&lt;/th&gt;
&lt;th&gt;Landed or lands&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;th&gt;Automated path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Impeller on desktop&lt;/td&gt;
&lt;td&gt;Flutter 3.47, Aug 2026&lt;/td&gt;
&lt;td&gt;Default renderer, opt-out to be removed&lt;/td&gt;
&lt;td&gt;None; requires validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CocoaPods to SwiftPM&lt;/td&gt;
&lt;td&gt;Default since 3.44, May 2026&lt;/td&gt;
&lt;td&gt;Dependency manager, plugins degrade&lt;/td&gt;
&lt;td&gt;Partial, plugin-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Material and Cupertino out of SDK&lt;/td&gt;
&lt;td&gt;Scheduled Nov 2026 stable&lt;/td&gt;
&lt;td&gt;Formal deprecation, packages split out&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dart fix --apply --code=migrate_design_widgets&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KGP to built-in Kotlin&lt;/td&gt;
&lt;td&gt;Flutter 3.44&lt;/td&gt;
&lt;td&gt;Hard build failure under AGP 9&lt;/td&gt;
&lt;td&gt;Documented manual migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS and macOS minimum floors&lt;/td&gt;
&lt;td&gt;Flutter 3.47&lt;/td&gt;
&lt;td&gt;iOS 13 to 15, macOS 10.15 to 12&lt;/td&gt;
&lt;td&gt;None; a product decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UIScene lifecycle&lt;/td&gt;
&lt;td&gt;iOS 27 SDK&lt;/td&gt;
&lt;td&gt;App fails to launch if unadopted&lt;/td&gt;
&lt;td&gt;None; required adoption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Question 6: how are you handling the store deadlines?
&lt;/h2&gt;

&lt;p&gt;Two dates bind every Flutter app this year, and only one of them is in the future.&lt;/p&gt;

&lt;p&gt;Google Play's &lt;a href="https://support.google.com/googleplay/android-developer/answer/11926878?hl=en" rel="noopener noreferrer"&gt;target API level policy&lt;/a&gt; states that "starting August 31, 2026: New apps and app updates must target Android 16 (API level 36) or higher to be submitted to Google Play", with Wear OS and Android Automotive at API 35 and Android TV and Android XR at API 34. The consequence of missing it is worth reading precisely, because it is routinely overstated: non-compliant apps "will stop being discoverable to all Google Play users whose devices run Android OS versions newer than your app's target API level." Existing installs are unaffected, and an extension to 1 November 2026 can be requested.&lt;/p&gt;

&lt;p&gt;Apple's requirement is already live. Apple's &lt;a href="https://developer.apple.com/news/?id=ueeok6yw" rel="noopener noreferrer"&gt;developer news post of 3 February 2026&lt;/a&gt; set out that "starting April 28, 2026, apps and games uploaded to App Store Connect need to meet the following minimum requirements: iOS and iPadOS apps must be built with the iOS 26 &amp;amp; iPadOS 26 SDK or later", and Apple's &lt;a href="https://developer.apple.com/app-store/submitting/" rel="noopener noreferrer"&gt;submission guidance&lt;/a&gt; now states the same requirement in the past tense.&lt;/p&gt;

&lt;p&gt;Flutter 3.47's defaults already put you at &lt;code&gt;targetSdkVersion&lt;/code&gt; 36, so a current toolchain satisfies Google Play by construction. That is the actual argument for staying current: the deadline is free if you are up to date and a project if you are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 7: what evidence do you have, and what do you not have?
&lt;/h2&gt;

&lt;p&gt;This is the question that separates an engineering partner from a sales one, and the honest answer disqualifies most Flutter marketing.&lt;/p&gt;

&lt;p&gt;There is no credible, independently measured Flutter versus React Native benchmark in the public record. The startup-time and cost-reduction figures that circulate, including a widely repeated cold-start comparison, appear only in agency blogs with no traceable measurement behind them. If a partner quotes you a percentage for how much faster or cheaper Flutter is, ask for the source. There usually is not one.&lt;/p&gt;

&lt;p&gt;The defensible evidence is narrower and more useful.&lt;/p&gt;

&lt;p&gt;Adoption, from Google directly: the &lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-44" rel="noopener noreferrer"&gt;Flutter 3.44 release post from I/O 2026&lt;/a&gt; states Flutter is "the second most popular development SDK for mobile on both major app stores with over 1.5 million monthly developers, a 50% increase in just one year", with over 1.3 billion package downloads in the preceding 30 days and more than 1,700 contributors landing 5,800 changes in the core repository over the year.&lt;/p&gt;

&lt;p&gt;Survey position, from a neutral source: the &lt;a href="https://survey.stackoverflow.co/2024/technology" rel="noopener noreferrer"&gt;Stack Overflow 2024 Developer Survey&lt;/a&gt; put Flutter at 9.4% and React Native at 8.4% among all respondents, and 9.4% against 9.0% among professional developers. That is the most recent Stack Overflow edition carrying the comparison; the 2025 survey did not publish one, so any Flutter percentage attributed to a 2025 Stack Overflow survey is mis-sourced.&lt;/p&gt;

&lt;p&gt;A first-party migration account: LG Electronics wrote in its &lt;a href="https://webostv.developer.lge.com/news/2024-07-15-new-and-successful-experiment-of-webos-with-flutter" rel="noopener noreferrer"&gt;webOS Flutter experiment post&lt;/a&gt; that "without any optimization whatsoever, our Flutter rewrite launched twice as fast as our original app, consumed less runtime memory, and felt more responsive", and that Flutter gave them "increased developer productivity, better runtime app performance, and ... a smoother experience finding and hiring talented developers." The caveat belongs with the quote: LG was comparing Flutter to a React-based TV app, not to React Native on a phone, and the figure is LG's own.&lt;/p&gt;

&lt;p&gt;If the decision is genuinely open between the two frameworks, the hiring market is usually the deciding variable rather than the runtime, which is the case we make in &lt;a href="https://ecorpit.com/react-native-vs-flutter-hiring-decision-framework-2026/" rel="noopener noreferrer"&gt;the React Native versus Flutter hiring decision framework&lt;/a&gt; and in our &lt;a href="https://ecorpit.com/react-native-app-development-company/" rel="noopener noreferrer"&gt;react native app development company&lt;/a&gt; page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance: who actually maintains this now
&lt;/h2&gt;

&lt;p&gt;Worth knowing before you commit a five-year product to it.&lt;/p&gt;

&lt;p&gt;Canonical is now lead maintainer of Flutter Desktop. The &lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-44" rel="noopener noreferrer"&gt;3.44 release post&lt;/a&gt; announced "an expanded partnership with Canonical, who will now serve as the lead maintainer and Strategic Steward for Flutter Desktop", leading the desktop roadmap and overseeing the Linux, Windows and macOS embedders.&lt;/p&gt;

&lt;p&gt;Google is no longer the majority contributor. The &lt;a href="https://flutter.dev/blog/flutter-q2-2026-survey" rel="noopener noreferrer"&gt;Q2 2026 survey post&lt;/a&gt; states that "over 70% of currently open PRs in the Flutter project come from contributors outside of Google", and describes a proposed four-tier Contributor Ladder running Contributor, Reviewer, Committer, Maintainer, with public data-driven promotion in a new &lt;code&gt;flutter/contributors&lt;/code&gt; repository, "replacing the old informal sponsorship model entirely."&lt;/p&gt;

&lt;p&gt;Two related claims are circulating that we could not source to anything primary, so treat them as unfounded: there is no "Flutter Foundation", and 2026-dated claims about Google layoffs hitting the Flutter team trace to no primary source.&lt;/p&gt;

&lt;p&gt;One nuance for Mac-based teams: Flutter's &lt;a href="https://docs.flutter.dev/reference/supported-platforms" rel="noopener noreferrer"&gt;supported platforms page&lt;/a&gt; warns that "as Apple phases out Intel-based Macs, Flutter is phasing out support for Intel (x64) hardware", and the 3.47 post confirms automated test runs on Intel hardware are disabled with CLI warnings that "will become errors in a future release." No release number or date has been published for the removal. If a partner gives you one, they are guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  India-specific considerations
&lt;/h2&gt;

&lt;p&gt;Two things shift the calculus for an India-based build.&lt;/p&gt;

&lt;p&gt;Talent depth in Flutter is genuinely good and the rate differential is real, but the migration calendar above is where offshore engagements most often go wrong. A team hired on a fixed scope quoted before August 2026 has no contractual room for the November Material deprecation. Contract for a maintenance allowance tied to the four published stable releases rather than treating each one as a change request.&lt;/p&gt;

&lt;p&gt;Second, if the app handles personal data of users in India, the Digital Personal Data Protection Rules commence in tranches with consent and notice duties landing 13 May 2027. That is an architecture decision to take during the build, not a compliance task afterwards. Our view is in the &lt;a href="https://ecorpit.com/dpdp-act-engineering-playbook-indian-startups-2026/" rel="noopener noreferrer"&gt;DPDP Act engineering playbook for Indian startups&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we build, and how the engagement runs
&lt;/h2&gt;

&lt;p&gt;eCorpIT builds Flutter applications for mobile and, where it fits, desktop, with the migration calendar written into the plan rather than discovered later. Our delivery process is discovery and platform-floor decisions, architecture and state-management selection, build in two-week increments with a running app from sprint two, hardening and security review under our ISO 27001:2022 practices, and launch with an agreed upgrade cadence covering the four stable releases a year.&lt;/p&gt;

&lt;p&gt;We were founded in 2021, are based in Gurugram, and hold CMMI Level 5, MSME certification and ISO 27001:2022, with partner relationships including AWS, Microsoft and Google. On engagement models we run a fixed-scope build where the specification is settled and the delivery window sits clear of a known migration, a dedicated senior-led team on a monthly retainer where the roadmap is still moving, and a milestone-based build-and-transfer where you plan to take the code in-house. For a Q4 2026 delivery we will usually recommend against fixed scope, for the Material reason set out above, and we would rather say that up front than invoice for it later.&lt;/p&gt;

&lt;p&gt;If you are assembling requirements, our &lt;a href="https://ecorpit.com/mobile-app-development-rfp-template/" rel="noopener noreferrer"&gt;mobile app development RFP template&lt;/a&gt; covers the questions above in a form you can send to several firms at once, and our &lt;a href="https://ecorpit.com/flutter-3-44-production-upgrade-guide-2026/" rel="noopener noreferrer"&gt;Flutter 3.44 production upgrade guide&lt;/a&gt; shows the level of detail an upgrade actually involves. For a view on delivery models, see &lt;a href="https://ecorpit.com/agency-vs-freelancer-mobile-app/" rel="noopener noreferrer"&gt;agency versus freelancer for mobile app builds&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the current stable version of Flutter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Flutter 3.47.0, released on 12 August 2026, alongside Dart 3.13.0 the same day. It sits at the top of the official release notes ahead of 3.44.0, 3.41.0 and 3.38.0. Flutter ships four stable releases a year on a published schedule, with 3.50 due in November 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Skia still used in Flutter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only on the web. Impeller is the only supported renderer on iOS, is enabled by default on Android API 29 and above with an OpenGL fallback below that, and became the default on macOS, Windows and Linux in Flutter 3.47. The documentation says desktop opt-out will be removed in a future release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is happening to Material and Cupertino widgets?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They are moving out of the core SDK into standalone packages. Flutter's 3.47 release post states the original design libraries inside the core SDK are scheduled for formal deprecation in the November 2026 stable release. An automated migration exists as dart fix with the migrate_design_widgets code, but themed apps need manual work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What minimum OS versions does Flutter 3.47 support?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The supported platforms matrix gives Android API 24 to 37 and iOS 15 to 26, with Android 23 and earlier and iOS 14 and earlier unsupported. Flutter 3.47 raised the iOS minimum from 13 to 15 and the macOS minimum from 10.15 to 12, the latter to support Xcode 27.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When do we have to target Android API 36?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From 31 August 2026, new apps and app updates must target Android 16, API level 36, to be submitted to Google Play. Missing it does not remove the app; it stops being discoverable to users on newer Android versions. An extension to 1 November 2026 can be requested if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Flutter more popular than React Native?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most recent Stack Overflow survey carrying the comparison is 2024, which put Flutter at 9.4% and React Native at 8.4% among all respondents. Google reported over 1.5 million monthly Flutter developers at I/O 2026, a 50% year-on-year increase. The 2025 survey published no such figure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are Flutter benchmarks against React Native reliable?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Generally not. There is no independently measured public benchmark comparing the two, and the startup-time and cost-saving percentages that circulate appear only in agency marketing without traceable measurement. Ask any partner quoting such figures for the primary source before treating the number as real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who maintains Flutter now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google still leads the project, but Canonical became lead maintainer and Strategic Steward for Flutter Desktop in May 2026, overseeing the Linux, Windows and macOS embedders. Flutter reports that over 70% of currently open pull requests come from contributors outside Google, with a formal Contributor Ladder proposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How eCorpIT can help
&lt;/h2&gt;

&lt;p&gt;eCorpIT builds and maintains Flutter applications with the release calendar treated as a planning input rather than a surprise. We are a Gurugram-based technology company founded in 2021, holding CMMI Level 5, MSME certification and ISO 27001:2022, with senior-led engineering teams working across mobile, desktop and backend. If you are choosing between firms, the seven questions above work as a screening instrument whether or not you send them to us. If you would like us to review an existing Flutter codebase against the 2026 migration calendar before you commit to a rebuild, &lt;a href="https://ecorpit.com/contact-us/" rel="noopener noreferrer"&gt;contact us&lt;/a&gt; and we will tell you what we find.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/release/release-notes" rel="noopener noreferrer"&gt;Flutter release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/install/archive" rel="noopener noreferrer"&gt;Flutter install archive and 2026 release schedule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dart.dev/resources/whats-new" rel="noopener noreferrer"&gt;Dart, what's new&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-47" rel="noopener noreferrer"&gt;Flutter, "What's new in Flutter 3.47", 12 August 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-44" rel="noopener noreferrer"&gt;Flutter, "What's new in Flutter 3.44", 20 May 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/perf/impeller" rel="noopener noreferrer"&gt;Flutter, Impeller rendering engine documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/reference/supported-platforms" rel="noopener noreferrer"&gt;Flutter, supported platforms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/release/breaking-changes" rel="noopener noreferrer"&gt;Flutter, breaking changes index&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin" rel="noopener noreferrer"&gt;Flutter, migrate to built-in Kotlin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.flutter.dev/release/compatibility-policy" rel="noopener noreferrer"&gt;Flutter, compatibility policy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://flutter.dev/blog/flutter-q2-2026-survey" rel="noopener noreferrer"&gt;Flutter, Q2 2026 developer survey results&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://flutter.dev/blog/flutter-darts-2026-roadmap" rel="noopener noreferrer"&gt;Flutter and Dart 2026 roadmap&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/googleplay/android-developer/answer/11926878?hl=en" rel="noopener noreferrer"&gt;Google Play, target API level requirements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/news/?id=ueeok6yw" rel="noopener noreferrer"&gt;Apple Developer news, "Upcoming requirements for app submissions", 3 February 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/app-store/submitting/" rel="noopener noreferrer"&gt;Apple, submitting to the App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://survey.stackoverflow.co/2024/technology" rel="noopener noreferrer"&gt;Stack Overflow 2024 Developer Survey, technology section&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webostv.developer.lge.com/news/2024-07-15-new-and-successful-experiment-of-webos-with-flutter" rel="noopener noreferrer"&gt;LG Electronics, webOS and Flutter experiment&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Last updated: 15 August 2026.&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
    <item>
      <title>Flutter Desktop Input Design — Where Does the Enter Key Actually Go?</title>
      <dc:creator>yuelinghuashu</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:37:15 +0000</pubDate>
      <link>https://dev.to/yuelinghuashu/flutter-desktop-input-design-where-does-the-enter-key-actually-go-1d0i</link>
      <guid>https://dev.to/yuelinghuashu/flutter-desktop-input-design-where-does-the-enter-key-actually-go-1d0i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;From "pressing Enter does nothing" to "the Enter on the arrow-key area still inserts a newline", these desktop input field pitfalls ultimately trace back to a Focus model problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prologue: a bug reported by a user
&lt;/h2&gt;

&lt;p&gt;"After typing in the input field, the first Enter inserts a newline, and only the second one actually submits."&lt;/p&gt;

&lt;p&gt;This is an extremely representative problem in Flutter desktop development: &lt;strong&gt;mobile input logic cannot be directly transplanted to desktop&lt;/strong&gt;. On mobile, the "send" button on the soft keyboard naturally triggers &lt;code&gt;onSubmitted&lt;/code&gt;; on desktop, there's a physical keyboard where Enter, Shift, and arrow keys are independent visible physical events whose semantics must be defined by the developer.&lt;/p&gt;

&lt;p&gt;(Background: this input field comes from an AI-driven interactive narrative app, where the user enters instructions as a "Fate" and the AI unfolds the story. The input field and the streaming reply are the two core interaction entry points of this app, so their details deserve careful polishing.)&lt;/p&gt;

&lt;p&gt;My initial approach was very "intuitive": wrap the TextField with an outer &lt;code&gt;Focus&lt;/code&gt; and intercept the Enter key inside it. That produced the exact bug at the start of this article — the first Enter became a newline.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The real propagation path of keyboard events
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the intuition is wrong
&lt;/h3&gt;

&lt;p&gt;Most people (including me) write it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Expanded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;onKeyEvent:&lt;/span&gt; &lt;span class="n"&gt;_handleKeyEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// outer Focus intercepts&lt;/span&gt;
    &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;maxLines:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// desktop: multiline&lt;/span&gt;
      &lt;span class="nl"&gt;textInputAction:&lt;/span&gt; &lt;span class="n"&gt;TextInputAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks like &lt;code&gt;onKeyEvent&lt;/code&gt; should receive every key press. But in reality, &lt;strong&gt;a keyboard event first reaches the node that actually has focus&lt;/strong&gt; — the &lt;code&gt;EditableText&lt;/code&gt; inside the TextField — not the &lt;code&gt;Focus&lt;/code&gt; wrapper you put around it.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;maxLines: null&lt;/code&gt; + &lt;code&gt;textInputAction: newline&lt;/code&gt;, when &lt;code&gt;EditableText&lt;/code&gt; receives Enter it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inserts a newline internally&lt;/li&gt;
&lt;li&gt;Returns &lt;code&gt;KeyEventResult.handled&lt;/code&gt; (marking the event as consumed)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once an event is &lt;code&gt;handled&lt;/code&gt;, it &lt;strong&gt;no longer bubbles up&lt;/strong&gt; to the outer &lt;code&gt;Focus&lt;/code&gt;. Your &lt;code&gt;_handleKeyEvent&lt;/code&gt; never receives the event and obviously can't intercept it. The first Enter becomes a newline; the second one "happens" to submit.&lt;/p&gt;

&lt;p&gt;The word "bubbling" naturally makes frontend readers think of &lt;strong&gt;JS DOM event bubbling&lt;/strong&gt;. The two do share a commonality: the event starts at a point, propagates up a chain, and can be stopped midway if consumed. But the details of "propagation path" and "midway stop" are &lt;strong&gt;completely different&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;JS DOM events&lt;/th&gt;
&lt;th&gt;Flutter keyboard events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What determines the propagation path&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DOM tree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Focus Chain&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is visual containment = propagation path?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (focus relation ≠ containment relation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Propagation direction&lt;/td&gt;
&lt;td&gt;capture down → target → bubble up&lt;/td&gt;
&lt;td&gt;focus node → up the focus chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Midway stop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stopPropagation()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return &lt;code&gt;KeyEventResult.handled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key difference&lt;/td&gt;
&lt;td&gt;any DOM ancestor receives the event&lt;/td&gt;
&lt;td&gt;inner node can &lt;strong&gt;consume early&lt;/strong&gt;; the event is cut off before bubbling reaches ancestors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In JS, an outer &lt;code&gt;div&lt;/code&gt; wrapping an inner &lt;code&gt;input&lt;/code&gt; &lt;strong&gt;always&lt;/strong&gt; receives the event — visual containment is the propagation path, so intercepting at the outer layer is natural. But in Flutter, &lt;strong&gt;the event travels along the focus chain, not the widget containment tree&lt;/strong&gt;: the &lt;code&gt;EditableText&lt;/code&gt; inside the TextField is the current focus node, and the event starts there and propagates up the focus chain. The outer &lt;code&gt;Focus&lt;/code&gt;, as an ancestor of &lt;code&gt;EditableText&lt;/code&gt;, &lt;strong&gt;is indeed on the focus chain&lt;/strong&gt; — but the problem is that &lt;code&gt;EditableText&lt;/code&gt; returns &lt;code&gt;KeyEventResult.handled&lt;/code&gt; when handling Enter, so &lt;strong&gt;the event bubble is cut off before it reaches the outer &lt;code&gt;Focus&lt;/code&gt;&lt;/strong&gt;. That's the real reason "wrapping the TextField with an outer Focus fails to intercept Enter": it's not that the node is off the chain, but that the event is already consumed before it arrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The correct mounting point
&lt;/h3&gt;

&lt;p&gt;Bind the keyboard event handler &lt;strong&gt;directly to the TextField's own &lt;code&gt;FocusNode&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="n"&gt;FocusNode&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_focusNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FocusNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onKeyEvent:&lt;/span&gt; &lt;span class="n"&gt;_handleKeyEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// No outer Focus wrapper needed in build&lt;/span&gt;
&lt;span class="n"&gt;Expanded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;_focusNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way &lt;code&gt;_handleKeyEvent&lt;/code&gt; runs before &lt;code&gt;EditableText&lt;/code&gt; processes the event. Enter (without Shift) returns &lt;code&gt;handled&lt;/code&gt; to prevent the newline and send; Shift+Enter returns &lt;code&gt;ignored&lt;/code&gt; to let the TextField insert a newline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: in Flutter, "wrapping a widget" is not the same as "being able to intercept keyboard events from descendant widgets". If you want to intercept something, mount the listener on the node the event actually passes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The same "Enter", two key codes
&lt;/h2&gt;

&lt;p&gt;After fixing the "first Enter creates a newline" bug, another user reported: "&lt;strong&gt;the Enter on the arrow-key area still inserts a newline&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;Same Enter key — why does the letter area work but the arrow-key area doesn't?&lt;/p&gt;

&lt;p&gt;Because in Flutter, these two "Enters" are &lt;strong&gt;different key codes&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;&lt;code&gt;LogicalKeyboardKey&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main keyboard Enter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;enter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enter above the arrow-key area / on the numpad&lt;/td&gt;
&lt;td&gt;&lt;code&gt;numpadEnter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And my check was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;numpadEnter&lt;/code&gt; doesn't match, so &lt;code&gt;_handleKeyEvent&lt;/code&gt; returns &lt;code&gt;ignored&lt;/code&gt; for it, the event passes through to the TextField, and a newline is inserted as usual.&lt;/p&gt;

&lt;p&gt;The fix is simply to match both key codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logicalKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;numpadEnter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: a desktop keyboard is not "one key = one semantic". The same physical action (pressing Enter) can map to different key codes in different areas — especially when matching keys, think about the existence of areas beyond the main keyboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Shift+Enter semantics must not be lost
&lt;/h2&gt;

&lt;p&gt;Desktop has a common convention: &lt;strong&gt;Enter to send, Shift+Enter for a newline&lt;/strong&gt;. This is nearly universal in chat apps, terminals, and editors.&lt;/p&gt;

&lt;p&gt;The implementation detail is that Shift+Enter should &lt;strong&gt;pass through&lt;/strong&gt; to &lt;code&gt;EditableText&lt;/code&gt; rather than constructing a newline yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HardwareKeyboard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isShiftPressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Shift+Enter → let the TextField insert a newline&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;KeyEventResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is "passing through" more reliable than "constructing a newline yourself"?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Letting EditableText handle the newline correctly maintains the cursor position, selection, and IME composition state&lt;/li&gt;
&lt;li&gt;Pushing &lt;code&gt;\n&lt;/code&gt; into the controller yourself can corrupt the cursor context during input method (e.g., Chinese pinyin) composition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Shift state check uses &lt;code&gt;HardwareKeyboard.instance.isShiftPressed&lt;/code&gt; — the global hardware keyboard state query Flutter currently provides. Worth noting: &lt;code&gt;KeyDownEvent&lt;/code&gt; itself &lt;strong&gt;does not carry modifier state&lt;/strong&gt; (&lt;code&gt;KeyEvent&lt;/code&gt; only has fields like &lt;code&gt;physicalKey&lt;/code&gt; / &lt;code&gt;logicalKey&lt;/code&gt; / &lt;code&gt;character&lt;/code&gt; / &lt;code&gt;timeStamp&lt;/code&gt;, no &lt;code&gt;modifiers&lt;/code&gt;), so checking Shift must rely on the &lt;code&gt;HardwareKeyboard&lt;/code&gt; global singleton.&lt;/p&gt;

&lt;p&gt;The global state has a boundary worth noticing: it reflects the hardware state "&lt;strong&gt;right now&lt;/strong&gt;", not "at the instant of that event". In scenarios like rapid successive key presses, or releasing a modifier key right after a dialog steals focus, it could theoretically read a lagged state. Flutter's future &lt;code&gt;KeyEvent&lt;/code&gt; API direction is to have events carry a &lt;code&gt;modifiers&lt;/code&gt; snapshot (like Web's &lt;code&gt;KeyboardEvent&lt;/code&gt;), at which point event-level checks will be more reliable than global state — but in the current Flutter version, &lt;code&gt;HardwareKeyboard.instance.isShiftPressed&lt;/code&gt; is the standard, usable approach.&lt;/p&gt;

&lt;p&gt;Also worth mentioning: here you &lt;strong&gt;neither need nor should&lt;/strong&gt; build your own "modifier state cache" (manually setting true on KeyDown and false on KeyUp) — because &lt;code&gt;HardwareKeyboard&lt;/code&gt; itself is a global state maintained by the Flutter framework: it keeps its state strictly consistent with the event stream through KeyDown/KeyUp events plus a synthesized-event synchronization mechanism. For example, when focus switching causes a Shift release event to be lost, Flutter injects a synthesized event to correct the state. A hand-rolled cache is actually more likely to fail in edge cases like focus switching and synthesized events — that's exactly the complexity the framework handles for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Input history: Widget lifecycle ≠ data lifecycle
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem: ↑ / ↓ stops working after leaving and re-entering
&lt;/h3&gt;

&lt;p&gt;After adding the "↑ / ↓ to recall the last 5 inputs" shortcuts on desktop, the first round of testing was fine — send a few messages, press ↑ to recall them one by one. But a user said: "after leaving and re-entering, the ↑ key doesn't work."&lt;/p&gt;

&lt;p&gt;The reason is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_InputBarState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InputBar&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// ← pure memory, cleared when the widget is destroyed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The input history lives in &lt;code&gt;State&lt;/code&gt;. While playing, &lt;code&gt;InputBar&lt;/code&gt; stays alive and history accumulates normally; once you leave the narrative page and &lt;code&gt;InputBar&lt;/code&gt; is destroyed and rebuilt, &lt;code&gt;_history&lt;/code&gt; is reset to empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Widget lifecycle ≠ data lifecycle&lt;/strong&gt;. &lt;code&gt;State&lt;/code&gt; exists for "UI state" (scroll position, current input-box content), not for "user data" (input history that must survive across sessions). Putting persistent data in &lt;code&gt;State&lt;/code&gt; is an anti-pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  The right approach: state lifting + persistence
&lt;/h3&gt;

&lt;p&gt;Following Riverpod's &lt;code&gt;Notifier&lt;/code&gt; pattern, lift the input history to a global Provider and persist it with &lt;code&gt;SharedPreferences&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InputHistoryNotifier&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Notifier&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxHistory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'mephisto_input_history'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;last&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// adjacent dedup&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxHistory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;removeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// An optional initializer: restore from persistence&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After changing &lt;code&gt;InputBar&lt;/code&gt; from &lt;code&gt;State&lt;/code&gt; to &lt;code&gt;ConsumerState&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write to the Provider on send, read from the Provider after rebuild — history survives across sessions.&lt;/p&gt;

&lt;h3&gt;
  
  
  A design decision: global sharing, or per-contract isolation?
&lt;/h3&gt;

&lt;p&gt;A user raised a very reasonable concern: "if I have multiple sub-versions in progress, are all their histories saved? Does it affect performance?"&lt;/p&gt;

&lt;p&gt;I ultimately chose a &lt;strong&gt;global single list&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Constant storage: one &lt;code&gt;SharedPreferences&lt;/code&gt; key, at most 5 short text entries (a few KB), &lt;strong&gt;does not grow with the number of sub-version files&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Reasonable semantics: different scripts/branches often use similar directional words ("investigate", "ask", "go to"), so global sharing is actually more convenient&lt;/li&gt;
&lt;li&gt;Simple to implement: no &lt;code&gt;Map&amp;lt;fileName, List&amp;lt;String&amp;gt;&amp;gt;&lt;/code&gt; serialization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-sub-version isolation (&lt;code&gt;Map&lt;/code&gt; structure) would pose no performance pressure either (each sub-version is just a few KB), but it's more complex to implement for limited benefit. For a personal project, a global single list is the right "good enough and simple" trade-off.&lt;/p&gt;

&lt;p&gt;A forward-looking risk: the global single list's write is an &lt;strong&gt;async &lt;code&gt;setString&lt;/code&gt;&lt;/strong&gt;; if you ever support &lt;strong&gt;multi-window / multi-tab simultaneous editing&lt;/strong&gt;, there's a theoretical chance of concurrent write clobbering (two windows each push and overwrite each other). The current design fits single-window serial scenarios; if multi-window arrives, writes need debounce merging, or switch to file locks / a database (e.g., &lt;code&gt;sqlite&lt;/code&gt;) for atomicity.&lt;/p&gt;

&lt;p&gt;And one more extreme-scenario trade-off: &lt;code&gt;SharedPreferences.setString&lt;/code&gt; is an async write. If the user closes the app or the system hard-kills the process before the &lt;code&gt;await&lt;/code&gt; completes, the last write can be lost. Since input history is &lt;strong&gt;"auxiliary convenience" rather than "core asset"&lt;/strong&gt; (losing it only means the ↑ key recalls one less entry; it doesn't corrupt narrative data), this extremely-low-probability loss is acceptable — hence no double-write or transaction log over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. How to test these interaction boundaries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keyboard events: simulating the desktop platform
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;testWidgets&lt;/code&gt; runs under FakeAsync by default, and you can use &lt;code&gt;sendKeyEvent&lt;/code&gt; to simulate key presses directly. The key is &lt;strong&gt;specifying the platform&lt;/strong&gt; — &lt;code&gt;InputBar._isDesktop&lt;/code&gt; is determined by &lt;code&gt;Theme.of(context).platform&lt;/code&gt;, and by default it's Android, not desktop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pumpWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildInputBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onSend:&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// internally sets ThemeData(platform: linux)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enterText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;'fate instruction'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendKeyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;platform:&lt;/span&gt; &lt;span class="s"&gt;'linux'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'fate instruction'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// submits on the first Enter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to testing Numpad Enter and ↑ / ↓ recall.&lt;/p&gt;

&lt;h3&gt;
  
  
  The FakeAsync limitation: real async IO doesn't complete automatically
&lt;/h3&gt;

&lt;p&gt;When a test involves "persist → rebuild → restore", I hit a snag: inside &lt;code&gt;testWidgets&lt;/code&gt;' FakeAsync, &lt;strong&gt;the SharedPreferences read Future doesn't complete automatically&lt;/strong&gt; — &lt;code&gt;pumpAndSettle&lt;/code&gt; only drives scheduled frames, not pure async IO.&lt;/p&gt;

&lt;p&gt;My initial "input history persistence" widget test never passed: write history in the first session → destroy and rebuild → press ↑ and get nothing. I tried &lt;code&gt;runAsync&lt;/code&gt;, multi-stage &lt;code&gt;pump&lt;/code&gt;, and there was always a timing contradiction between the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion: don't force "persistence round-trip" and "UI recall" into a single widget test&lt;/strong&gt;. Splitting the tests is more stable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider-level unit test&lt;/strong&gt;: verify &lt;code&gt;push&lt;/code&gt; writes, restore after recreating the container (round-trip), dedup, cap, and JSON-corruption tolerance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget-level test&lt;/strong&gt;: verify ↑ / ↓ recall interaction behavior (on top of an already-mocked persistent Provider)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each focuses on its own concern, and neither is affected by the FakeAsync-vs-real-IO timing contradiction of the combined test.&lt;/p&gt;

&lt;p&gt;The Provider-level test skeleton looks like this — &lt;code&gt;SharedPreferences.setMockInitialValues&lt;/code&gt; handles the in-memory mock in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'round-trip: push then restore after recreating container'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMockInitialValues&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;container1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProviderContainer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;container1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'test history'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;container1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Recreate the container (simulating an app restart) → AutoLoadNotifier restores from the in-memory mock&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;container2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProviderContainer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;container2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputHistoryProvider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'test history'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: the Provider-level &lt;code&gt;load()&lt;/code&gt; is a pure async method that you can &lt;code&gt;await&lt;/code&gt; directly in a normal &lt;code&gt;test()&lt;/code&gt; without touching &lt;code&gt;testWidgets&lt;/code&gt;' FakeAsync — this is the testability dividend of extracting persistence logic out of widgets.&lt;/p&gt;

&lt;p&gt;A further architectural direction: abstract persistence behind an interface (e.g., &lt;code&gt;InputHistoryStore&lt;/code&gt;), letting the Provider depend on the interface instead of directly on &lt;code&gt;SharedPreferences&lt;/code&gt; — tests inject an in-memory implementation, &lt;strong&gt;completely escaping the FakeAsync-vs-real-IO timing contradiction&lt;/strong&gt;. &lt;code&gt;setMockInitialValues&lt;/code&gt; is Flutter's built-in lightweight mock, sufficient for the current scenario; interface injection is the upgrade path when you need stricter isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The "boundary sense" of a desktop input field comes from understanding three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The propagation path of keyboard events&lt;/strong&gt;: the interceptor must be mounted on the real focus node (&lt;code&gt;FocusNode&lt;/code&gt;), not an outer wrapping widget — events don't bubble after &lt;code&gt;handled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The physical identity of key codes&lt;/strong&gt;: when matching physical keys (Enter), consider &lt;code&gt;numpadEnter&lt;/code&gt;; when modifier state (Shift) isn't carried by the event, query it via &lt;code&gt;HardwareKeyboard&lt;/code&gt; global state (and be aware of its "right now, not event-instant" boundary)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data lifecycle&lt;/strong&gt;: whether data goes in &lt;code&gt;State&lt;/code&gt; or is lifted to a Provider + persistence depends on whether it must survive across Widget lifecycles — and verify with layered tests (round-trip at the Provider layer, UI interaction at the widget layer)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These details almost never appear on mobile — mobile has only one soft keyboard Enter, and no concept of "files whose state must survive leaving and re-entering". But once you build for desktop, "functionally correct" and "experientially correct" diverge into a boundary that demands careful thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LogicalKeyboardKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flutter's "logical key" abstraction (after key-position + layout mapping), e.g., &lt;code&gt;enter&lt;/code&gt; / &lt;code&gt;numpadEnter&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PhysicalKeyboardKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Physical key position (USB HID code), independent of keyboard layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyEventResult&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keyboard event handler result: &lt;code&gt;handled&lt;/code&gt; (consumed, no longer propagates) / &lt;code&gt;ignored&lt;/code&gt; (passed through, continues propagating)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Focus Chain&lt;/td&gt;
&lt;td&gt;The path along which keyboard events propagate from "focus node → ancestors", unrelated to widget containment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HardwareKeyboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flutter's maintained global keyboard state (keys / modifiers / lock keys) query entry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Project: &lt;a href="https://github.com/yuelinghuashu/mephisto-gui" rel="noopener noreferrer"&gt;Mephisto&lt;/a&gt; (MIT License)&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>desktop</category>
      <category>keyboard</category>
    </item>
    <item>
      <title>Hybrid app development company: the 4 platform deadlines that decide your 2026 build</title>
      <dc:creator>Manu Shukla</dc:creator>
      <pubDate>Sat, 15 Aug 2026 05:46:45 +0000</pubDate>
      <link>https://dev.to/mr_manushukla/hybrid-app-development-company-the-4-platform-deadlines-that-decide-your-2026-build-27ea</link>
      <guid>https://dev.to/mr_manushukla/hybrid-app-development-company-the-4-platform-deadlines-that-decide-your-2026-build-27ea</guid>
      <description>&lt;h1&gt;
  
  
  Hybrid app development company: the 4 platform deadlines that decide your 2026 build
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Summary.&lt;/strong&gt; Google Play requires new apps and app updates to target Android 16 (API level 36) from 31 August 2026, with an extension available only to 1 November 2026. Apple has required every App Store Connect upload to be built with Xcode 26 and an iOS 26 SDK since 28 April 2026. Flutter 3.47, released 12 August 2026, moved Material and Cupertino into standalone &lt;code&gt;material_ui&lt;/code&gt; and &lt;code&gt;cupertino_ui&lt;/code&gt; packages and scheduled the in-SDK originals for formal deprecation in November. React Native 0.87, released 11 August 2026, raised its floor to Node.js 22, Android Gradle Plugin 9 and Kotlin 2.0. Four mandates in seven months, all touching the same codebase. That is the number a buyer should be looking at, and it is why the cost of a hybrid app in 2026 is dominated by upgrade cadence rather than by the initial build.&lt;/p&gt;

&lt;p&gt;Most pages that answer "hybrid app development company" compare Flutter against React Native on rendering performance. That comparison stopped deciding anything useful around 2024. Both stacks now compile to genuinely native UI, both ship on quarterly cadences, and both will force you into a migration this year whether you planned one or not. The question that decides your total cost is narrower: how quickly can your partner absorb a platform mandate without stopping feature work?&lt;/p&gt;

&lt;h2&gt;
  
  
  The four deadlines currently in force
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mandate&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What breaks if you miss it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Xcode 26 + iOS 26 SDK for all uploads&lt;/td&gt;
&lt;td&gt;Apple&lt;/td&gt;
&lt;td&gt;Since 28 April 2026&lt;/td&gt;
&lt;td&gt;App Store Connect rejects the upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target API 36 for new apps and updates&lt;/td&gt;
&lt;td&gt;Google Play&lt;/td&gt;
&lt;td&gt;31 August 2026&lt;/td&gt;
&lt;td&gt;Submission blocked; extension only to 1 November 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing apps must target API 35&lt;/td&gt;
&lt;td&gt;Google Play&lt;/td&gt;
&lt;td&gt;31 August 2026&lt;/td&gt;
&lt;td&gt;App stops being discoverable to new users on newer Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core Material and Cupertino deprecated&lt;/td&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;November 2026 stable&lt;/td&gt;
&lt;td&gt;Design libraries move to &lt;code&gt;pub.dev&lt;/code&gt; packages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google's Play Console documentation is specific about the second row and it is the one buyers underestimate. An app that stays on API 34 does not get removed. It becomes invisible: Google Play tells the user "this app is not available to install on their device because it was made for an older version of Android" when they arrive on a newer handset. Existing installs keep working. New acquisition stops. For a consumer app in India, where most new installs arrive on recent Android builds, that is a silent revenue cut rather than an outage anyone pages you about.&lt;/p&gt;

&lt;p&gt;The extension is narrower than it sounds. Google states that only apps already out of compliance receive a policy warning, and the extension form appears through that warning on the Policy status page. You cannot request one pre-emptively.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Hybrid" now means three different architectures
&lt;/h2&gt;

&lt;p&gt;The word covers three technical approaches with different failure modes. A company that says it "does hybrid" without naming which one is selling you a resourcing plan, not an architecture.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How the UI renders&lt;/th&gt;
&lt;th&gt;2026 flagship release&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;Own engine (Impeller), draws every pixel&lt;/td&gt;
&lt;td&gt;3.47, 12 August 2026&lt;/td&gt;
&lt;td&gt;Design-heavy consumer apps, one visual language on both stores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React Native&lt;/td&gt;
&lt;td&gt;Maps to real platform widgets via Fabric&lt;/td&gt;
&lt;td&gt;0.87, 11 August 2026&lt;/td&gt;
&lt;td&gt;Teams with existing React and TypeScript engineers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacitor&lt;/td&gt;
&lt;td&gt;Web app in a system WebView with native plugins&lt;/td&gt;
&lt;td&gt;8.x, &lt;code&gt;targetSdkVersion&lt;/code&gt; 36&lt;/td&gt;
&lt;td&gt;Content, commerce and internal tools where an existing web app exists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flutter's renderer is now Impeller everywhere. Version 3.47 made it the default on macOS, Windows and Linux after Android and iOS, and the Flutter team's stated reason is that Impeller "compiles a fixed set of shaders at build time rather than compiling them dynamically at runtime", removing first-play animation stutter. Version 3.47 also raised Flutter's minimum supported iOS from 13 to 15 and macOS from 10.15 to 12.&lt;/p&gt;

&lt;p&gt;React Native took the opposite route and finished a different job. Release 0.82 in October 2025 was, in the team's words, "the first React Native that runs entirely on the New Architecture", and 0.84 made Hermes V1 the default JavaScript engine in February 2026. There is no longer a legacy bridge to fall back to. That is good for performance and unforgiving for old dependencies.&lt;/p&gt;

&lt;p&gt;Capacitor is the honest choice when a working web application already exists. Capacitor 8 sets &lt;code&gt;compileSdkVersion&lt;/code&gt; and &lt;code&gt;targetSdkVersion&lt;/code&gt; to 36 and &lt;code&gt;minSdkVersion&lt;/code&gt; to 24 in &lt;code&gt;variables.gradle&lt;/code&gt;, requires Xcode 26 with an iOS deployment target of 15.0, and requires Node.js 22 or greater. It also removed &lt;code&gt;android.adjustMarginsForEdgeToEdge&lt;/code&gt; in favour of a System Bars core plugin, so edge-to-edge handling moves to CSS environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each stack actually demands from you this year
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Flutter: a design-system migration with a November clock
&lt;/h3&gt;

&lt;p&gt;Flutter 3.47 shipped &lt;code&gt;material_ui&lt;/code&gt; and &lt;code&gt;cupertino_ui&lt;/code&gt; at version 1.0 on &lt;code&gt;pub.dev&lt;/code&gt;. Migration runs through one command, &lt;code&gt;dart fix --apply --code=migrate_design_widgets&lt;/code&gt;, which rewrites imports. The deadline is explicit in the release post by Emma Twersky of the Flutter team: "The original design libraries inside the core SDK are scheduled for formal deprecation in the upcoming Fall stable release in November."&lt;/p&gt;

&lt;p&gt;Two other items in 3.47 carry real migration weight. The iOS 27 SDK mandates the &lt;code&gt;UIScene&lt;/code&gt; lifecycle for UIKit-based apps, and Flutter warns that apps built with Xcode 27 which have not adopted &lt;code&gt;UIScene&lt;/code&gt; will fail to launch. The Flutter CLI handles this automatically for most projects; custom &lt;code&gt;AppDelegate&lt;/code&gt; code and plugins on the legacy lifecycle need manual work. Separately, 92 of the top 100 iOS plugins have moved to Swift Package Manager, and CocoaPods is in maintenance mode, so unmigrated plugins will eventually stop working and already score lower on &lt;code&gt;pub.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Flutter 3.47 verifies against Java 17, Kotlin Gradle Plugin 2.4.0, Android Gradle Plugin 9.1.0 and Gradle 9.3.1, with &lt;code&gt;flutter.compileSdkVersion&lt;/code&gt; and &lt;code&gt;flutter.targetSdkVersion&lt;/code&gt; both at API 36. A Flutter project on 3.47 is already Play-compliant for 31 August. A Flutter project three releases behind is not, and the Gradle chain is what makes that upgrade slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  React Native: a toolchain floor, not a rewrite
&lt;/h3&gt;

&lt;p&gt;React Native 0.87, published 11 August 2026 by Zeya Peng and colleagues at Meta and Expo, is blunt about the cost: "This release makes the Strict TypeScript API the default JavaScript API, updates Metro to 0.87, adds experimental support for Swift Package Manager (SwiftPM). It also raises the minimum toolchain requirements: Node.js 22, Android Gradle Plugin 9, and Kotlin 2.0+."&lt;/p&gt;

&lt;p&gt;Strict TypeScript by default is the item that surprises teams. It was opt-in from 0.80 and was always described as a one-time breaking change; on a large codebase with untyped native modules, it surfaces work that has been deferred for two years. The AGP 9 requirement is separately awkward for apps carrying old native libraries.&lt;/p&gt;

&lt;p&gt;The upgrade path is gentler than Flutter's on the UI side. React Native 0.83 and 0.86 both shipped with no user-facing breaking changes, and 0.86 added edge-to-edge support on Android 15 and above, which is exactly what API 36 pushes you toward anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capacitor: cheapest to build, thinnest margin for platform change
&lt;/h3&gt;

&lt;p&gt;Capacitor's upgrade to 8 is mostly configuration, and &lt;code&gt;npx cap migrate&lt;/code&gt; does much of it. The residual risk sits in plugins and in Android's behaviour changes. Capacitor's own migration notes record that &lt;code&gt;scanOrientation&lt;/code&gt; in the Barcode Scanner and &lt;code&gt;lock&lt;/code&gt; in Screen Orientation have no effect on large screens from Android 16, that the opt-out property is temporary, and that it will stop working on Android 17. A tablet-facing app that pins orientation has a dated problem, not a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we build
&lt;/h2&gt;

&lt;p&gt;eCorpIT builds hybrid applications in all three stacks, and the selection is made against your existing engineering staff and your release cadence rather than a house preference. Typical work: consumer apps with a shared design system across both stores, commerce and D2C applications where an existing web front end is worth reusing, field-force and internal tools with offline data synchronization, and migration of ageing Cordova or legacy-architecture React Native apps onto a supported baseline.&lt;/p&gt;

&lt;p&gt;The migration work is now the larger half of the market. The real cost of a hybrid app is usually the upgrade you did not budget for, not the screens you did budget for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our five-step delivery process
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Baseline audit.&lt;/strong&gt; Application portfolio analysis of the current build: target SDK, Gradle and Kotlin versions, plugin inventory, and which of the four mandates above you currently fail. Output is a dated compliance position, not an estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture decision.&lt;/strong&gt; Stack selection written down with the reason, including the case against the stack we chose. Where an existing React or web codebase exists, that weighs more than framework benchmarks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build in two-week increments.&lt;/strong&gt; Working builds on TestFlight and Play internal testing from the first increment, so store-level problems surface early rather than at submission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store readiness.&lt;/strong&gt; Target API and SDK compliance, privacy manifests, age-rating responses in App Store Connect, and edge-to-edge behaviour verified on current Android before submission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cadence maintenance.&lt;/strong&gt; A standing quarterly upgrade slot mapped to the Flutter and React Native release trains, so a platform mandate consumes a planned sprint rather than an emergency.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Flutter with Dart and Impeller; React Native with the New Architecture, Hermes V1 and TypeScript; Capacitor with Angular, React or Vue where a web application already exists. Server side: Node.js, Python and .NET on AWS, Azure or Google Cloud. Offline data synchronization, push via Firebase Cloud Messaging or APNs, and server-side rendering for the web surface where SEO matters. CI/CD through GitHub Actions or Azure DevOps with signed builds to both stores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why eCorpIT
&lt;/h2&gt;

&lt;p&gt;eCorpIT is eCorp Information Technologies Private Limited, founded in 2021 and based in Sector 83, Gurugram. We are assessed at CMMI Level 5, certified to ISO 27001:2022, and MSME registered. We are partners of AWS, Microsoft, Google and Shopify. Teams are senior-led and multi-disciplinary rather than staffed from a bench.&lt;/p&gt;

&lt;p&gt;For regulated work we design applications aligned with DPDP Act 2023 requirements and with the framework your auditor applies. We do not claim to certify your app against a framework we do not hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engagement model and indicative bands
&lt;/h2&gt;

&lt;p&gt;We work in three models. A fixed-scope build suits a defined app with a signed specification and a store deadline. A dedicated team suits continuous product work and is billed monthly per engineer. A maintenance retainer covers the quarterly upgrade slot described above and is sized by the number of active plugins and native modules.&lt;/p&gt;

&lt;p&gt;We publish an engagement model rather than a rate card, because a hybrid quote that arrives before anyone has read your plugin inventory is a guess. The audit in step one is short and produces a fixed number. For current commercial terms, contact us through &lt;a href="https://ecorpit.com/contact-us/" rel="noopener noreferrer"&gt;/contact-us/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  India-specific considerations
&lt;/h2&gt;

&lt;p&gt;Android share is high enough in India that the 31 August discoverability rule matters more here than in most markets: an app stuck on API 34 loses new installs on the handsets that are actually selling. Device fragmentation also makes Impeller's build-time shader compilation worth more on mid-range hardware than benchmark charts on flagship devices suggest.&lt;/p&gt;

&lt;p&gt;On privacy, the DPDP Act 2023 governs personal data in your app regardless of stack, and the obligations follow the data rather than the framework. A WebView-based Capacitor app and a Flutter app handling the same consent flows carry the same duties.&lt;/p&gt;

&lt;p&gt;Cost comparison against offshore alternatives is covered in our &lt;a href="https://ecorpit.com/india-vs-us-app-development-cost-2026/" rel="noopener noreferrer"&gt;India versus US app development cost&lt;/a&gt; analysis, and the framework selection question specifically for hiring is in the &lt;a href="https://ecorpit.com/react-native-vs-flutter-hiring-decision-framework-2026/" rel="noopener noreferrer"&gt;React Native versus Flutter hiring decision framework&lt;/a&gt;. The Android deadline itself is covered in depth in our &lt;a href="https://ecorpit.com/android-target-api-36-play-store-deadline-migration-2026/" rel="noopener noreferrer"&gt;Android target API 36 migration guide&lt;/a&gt;, and the wider architectural picture sits in our &lt;a href="https://ecorpit.com/enterprise-mobile-app-development-guide/" rel="noopener noreferrer"&gt;enterprise mobile app development guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a hybrid app development company?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A hybrid app development company builds applications that run on both iOS and Android from one codebase, using Flutter, React Native or Capacitor rather than separate Swift and Kotlin projects. The useful distinction in 2026 is which of those three architectures the company actually maintains, because their upgrade obligations differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is hybrid still cheaper than native in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The initial build usually costs less because one team writes one codebase. The saving narrows over the life of the app, because a hybrid project inherits platform mandates from Apple and Google plus its own framework release train. Budget for a quarterly upgrade slot and the comparison stays favourable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which is better, Flutter or React Native?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither wins on rendering any more. Flutter 3.47 draws its own pixels through Impeller; React Native 0.87 maps to real platform widgets. Choose Flutter when a single visual language across both stores matters most, and React Native when you already employ React and TypeScript engineers who can maintain it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if my app misses the 31 August 2026 Play deadline?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;New submissions and updates are blocked. An existing app below API 35 stops being discoverable to new users on Android versions newer than its target, and Google Play tells those users the app was made for an older version of Android. Existing installations keep working normally throughout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still get an extension for the Android API 36 requirement?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google allows an extension to 1 November 2026, but only through the policy warning raised against a non-compliant app on the Policy status page in Play Console. You cannot request one in advance. Apps already compliant with API 35 have no warning and therefore no form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Capacitor still make sense in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It does when a maintained web application already exists and the mobile app is largely the same product. Capacitor 8 targets API 36 out of the box and defaults new iOS projects to Swift Package Manager. Verify orientation behaviour first, since Android 16 restricts orientation locking on large screens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does a hybrid app build take?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A defined first release with a signed specification typically runs in two-week increments with working store builds from the first increment. The variable that moves the date most is not screen count. It is the plugin and native-module inventory, which is why our baseline audit runs before any estimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to migrate my Flutter app to material_ui now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not immediately, but the window is short. Flutter 3.47 shipped &lt;code&gt;material_ui&lt;/code&gt; and &lt;code&gt;cupertino_ui&lt;/code&gt; at 1.0 as opt-in, and the in-SDK originals are scheduled for formal deprecation at the November stable release. The &lt;code&gt;dart fix --apply --code=migrate_design_widgets&lt;/code&gt; command handles most import rewriting automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  How eCorpIT can help
&lt;/h2&gt;

&lt;p&gt;We audit your current build against all four mandates in force, tell you which ones you fail today, and give you a fixed number to clear them. Where a new build is the right answer, we select the stack against your existing engineers rather than our preference, and we write down the case against the choice as well as for it. Teams are senior-led, and the quarterly upgrade slot is part of the engagement rather than a change request. Start with a short baseline audit through &lt;a href="https://ecorpit.com/contact-us/" rel="noopener noreferrer"&gt;/contact-us/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://support.google.com/googleplay/android-developer/answer/11926878?hl=en" rel="noopener noreferrer"&gt;Target API level requirements for Google Play apps&lt;/a&gt;, Play Console Help, Google&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-47" rel="noopener noreferrer"&gt;What's new in Flutter 3.47&lt;/a&gt;, Emma Twersky, The Flutter Blog, 12 August 2026&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://flutter.dev/blog/whats-new-in-flutter-3-44" rel="noopener noreferrer"&gt;What's new in Flutter 3.44&lt;/a&gt;, The Flutter Blog, 20 May 2026&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.apple.com/news/upcoming-requirements/" rel="noopener noreferrer"&gt;Upcoming Requirements: SDK minimum requirements&lt;/a&gt;, Apple Developer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://reactnative.dev/blog" rel="noopener noreferrer"&gt;React Native release announcements, versions 0.82 to 0.87&lt;/a&gt;, Meta and Expo, October 2025 to August 2026&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://reactnative.dev/architecture/overview" rel="noopener noreferrer"&gt;React Native architecture overview&lt;/a&gt;, Meta&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capacitorjs.com/docs/updating/8-0" rel="noopener noreferrer"&gt;Updating from Capacitor 7 to Capacitor 8&lt;/a&gt;, Capacitor Documentation, Ionic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capacitorjs.com/docs/updating/8-5" rel="noopener noreferrer"&gt;Updating to Capacitor 8.5&lt;/a&gt;, Capacitor Documentation, Ionic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capacitorjs.com/docs/android/setting-target-sdk" rel="noopener noreferrer"&gt;Setting target SDK in Capacitor for Android&lt;/a&gt;, Capacitor Documentation, Ionic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capacitorjs.com/docs/ios/spm" rel="noopener noreferrer"&gt;Swift Package Manager in Capacitor for iOS&lt;/a&gt;, Capacitor Documentation, Ionic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors" rel="noopener noreferrer"&gt;Swift Package Manager migration guide for plugin authors&lt;/a&gt;, Flutter documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.flutter.dev/release/breaking-changes" rel="noopener noreferrer"&gt;Flutter breaking changes and migration guides&lt;/a&gt;, Flutter documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pub.dev/packages/material_ui" rel="noopener noreferrer"&gt;material_ui package, version 1.0&lt;/a&gt;, pub.dev&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pub.dev/packages/cupertino_ui" rel="noopener noreferrer"&gt;cupertino_ui package, version 1.0&lt;/a&gt;, pub.dev&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/distribute/best-practices/develop/target-sdk.html" rel="noopener noreferrer"&gt;Migrating to a newer target SDK level&lt;/a&gt;, Android Developers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Last updated: 15 August 2026.&lt;/p&gt;

</description>
      <category>hybridappdevelopment</category>
      <category>flutter</category>
      <category>reactnative</category>
      <category>capacitor</category>
    </item>
  </channel>
</rss>
