<?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: Yevhen Beshkarov</title>
    <description>The latest articles on DEV Community by Yevhen Beshkarov (@ybeshkarov).</description>
    <link>https://dev.to/ybeshkarov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4149771%2Fa62933f9-d0ef-42c0-996f-f4cc6d54bb58.jpeg</url>
      <title>DEV Community: Yevhen Beshkarov</title>
      <link>https://dev.to/ybeshkarov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ybeshkarov"/>
    <language>en</language>
    <item>
      <title>Maven Central 403 or 429 in GitHub Actions: a Gradle fix</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/maven-central-403-or-429-in-github-actions-a-gradle-fix-3ebb</link>
      <guid>https://dev.to/ybeshkarov/maven-central-403-or-429-in-github-actions-a-gradle-fix-3ebb</guid>
      <description>&lt;p&gt;A Gradle build on a GitHub-hosted runner fails with a Maven Central 403: &lt;code&gt;Received status code 403 from server: Forbidden&lt;/code&gt; for a file on &lt;code&gt;repo.maven.apache.org&lt;/code&gt;. Or it gets 429, Too Many Requests. Nothing is wrong with your build. Maven Central limits the IP addresses that download the most, and GitHub's runners share their addresses with everyone else's jobs. A cache and retries don't fix it. Downloading less from Maven Central does: a Gradle init script that sends the downloads to Google's mirror of Maven Central, keeps Maven Central last for what the mirror doesn't have yet, and closes the path through the Gradle Plugin Portal.&lt;/p&gt;

&lt;p&gt;The CI of &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt; builds Flutter apps for Android in about 60 parallel jobs every night, and it has used this script since 2 October 2026. Since then it has run at least 3,917 Android jobs without one 403 or 429. The numbers below come from a fresh &lt;code&gt;flutter create&lt;/code&gt; app (Flutter 3.44.2, Android Gradle plugin 9.0.1, Kotlin 2.3.20), built on my Mac with an empty Gradle home on 11 November 2026, with Gradle 9.1.0 and 9.9.1. The script itself has nothing specific to Flutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Received status code 403 from server: Forbidden
&lt;/h2&gt;

&lt;p&gt;This is how it looked in our nightly run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; Could not resolve all files for configuration ':app:debugRuntimeClasspath'.
   &amp;gt; Could not download listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
      &amp;gt; Could not get resource 'https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.jar'.
         &amp;gt; Could not GET 'https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.jar'. Received status code 403 from server: Forbidden

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rate limit looks the same, with &lt;code&gt;Received status code 429 from server: Too Many Requests&lt;/code&gt;. Maven reports both as &lt;code&gt;status code: 403, reason phrase: Forbidden (403)&lt;/code&gt; or &lt;code&gt;429, reason phrase: Too Many Requests (429)&lt;/code&gt;. Gradle doesn't show the body of the answer, but if you request the file with &lt;code&gt;curl&lt;/code&gt; from a blocked address, Sonatype, which runs Maven Central, now explains it there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error 403 Forbidden: This IP has been blocked for excessive or automated consumption of Maven Central

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Maven Central 403 vs 429
&lt;/h2&gt;

&lt;p&gt;Since 2026, Sonatype treats the two codes as different signals, as its FAQ on &lt;a href="https://central.sonatype.org/faq/429-error/" rel="noopener noreferrer"&gt;429 errors&lt;/a&gt; and on &lt;a href="https://central.sonatype.org/faq/403-error-central/" rel="noopener noreferrer"&gt;403 errors&lt;/a&gt; explains.&lt;/p&gt;

&lt;p&gt;A 429 means you hit a consumption limit. The block is temporary, but it comes back and gets longer while the traffic stays the same, up to 30 days. Sonatype doesn't publish the limits, and it says that retries make things worse, so adding them to the workflow is the wrong fix.&lt;/p&gt;

&lt;p&gt;A 403 means the traffic counts as abuse. Such a block may cover a subnet, and in extreme cases a whole autonomous system (ASN).&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://central.sonatype.org/terms.html" rel="noopener noreferrer"&gt;terms of service&lt;/a&gt; of 31 March 2026 describe the model behind this: Maven Central stays free at levels that Sonatype considers reasonable, and high-volume consumers either reduce their traffic or pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why GitHub-hosted runners get blocked
&lt;/h2&gt;

&lt;p&gt;A job on a hosted runner leaves through IP addresses that many other jobs use too, and Sonatype counts the traffic per address and, since 2025, per organization behind several addresses. On 6 January 2026, Maven Central answered GitHub Actions with 403 for about four hours. The &lt;a href="https://status.maven.org/incidents/n7h6lz2sy5gw" rel="noopener noreferrer"&gt;status page&lt;/a&gt; gives the cause: security rules of its CDN that GitHub Actions addresses triggered. The issue about it, &lt;a href="https://github.com/actions/runner/issues/4180" rel="noopener noreferrer"&gt;actions/runner#4180&lt;/a&gt;, is still open with 75 upvotes, and the only reply from GitHub links that status page.&lt;/p&gt;

&lt;p&gt;Other CI services handled it on their side. In May 2026, Sonatype allow-listed the dedicated IP ranges of CircleCI, and Bitrise has routed Gradle builds through its own proxy by default since 30 April 2026. GitHub has an agreement that exempts its runners from the rate limits of Docker Hub, but I found nothing like it for Maven Central, so on GitHub Actions the fix is up to you.&lt;/p&gt;

&lt;p&gt;It happens more and more. GitHub issues that mention &lt;code&gt;repo.maven.apache.org&lt;/code&gt; and &lt;code&gt;Forbidden&lt;/code&gt; went from 11 in 2024 to 45 in 2025 and 108 in 2026, up to 11 November. Even the CI of Gradle itself ran into 429 in September (&lt;a href="https://github.com/gradle/gradle/pull/39257" rel="noopener noreferrer"&gt;gradle/gradle#39257&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Gradle cache does not fix the Maven Central 403
&lt;/h2&gt;

&lt;p&gt;Our job above did restore a cache of Gradle, just not its own. When a job doesn't find the cache for its key, &lt;code&gt;actions/cache&lt;/code&gt; restores the newest one under a shorter prefix, and that came from an app with Firebase. In that app, Guava replaces &lt;code&gt;com.google.guava:listenablefuture:1.0&lt;/code&gt; with an empty version, &lt;code&gt;9999.0-empty-to-avoid-conflict-with-guava&lt;/code&gt;, so the cache had no &lt;code&gt;listenablefuture-1.0.jar&lt;/code&gt;. Gradle went to Maven Central for it. In that run, 27 Android jobs topped up their caches from Maven Central, and one of them got the 403.&lt;/p&gt;

&lt;p&gt;A cache cuts the number of downloads. It doesn't take Maven Central out of the build: every new dependency, every new version, and every cache that misses a file still goes there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google's mirror of Maven Central
&lt;/h2&gt;

&lt;p&gt;Google keeps a copy of Maven Central in Cloud Storage, in three regions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://maven-central.storage-download.googleapis.com/maven2/
https://maven-central-eu.storage-download.googleapis.com/maven2/
https://maven-central-asia.storage-download.googleapis.com/maven2/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maven Central lists these three in its own &lt;a href="https://repo.maven.apache.org/maven2/.meta/repository-metadata.xml" rel="noopener noreferrer"&gt;repository-metadata.xml&lt;/a&gt;. Google's &lt;a href="https://storage-download.googleapis.com/maven-central/index.html" rel="noopener noreferrer"&gt;page about the mirror&lt;/a&gt; says that it isn't an officially supported product, so there is no SLA.&lt;/p&gt;

&lt;p&gt;The page also says that the American copy is updated 12 times a day. That isn't what I measured. I compared the &lt;code&gt;Last-Modified&lt;/code&gt; dates of the daily releases of &lt;code&gt;software.amazon.awssdk:s3&lt;/code&gt; on both hosts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;On Maven Central (UTC)&lt;/th&gt;
&lt;th&gt;On the mirror (UTC)&lt;/th&gt;
&lt;th&gt;Delay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.57.9&lt;/td&gt;
&lt;td&gt;4 Nov, 22:48&lt;/td&gt;
&lt;td&gt;6 Nov, 02:15&lt;/td&gt;
&lt;td&gt;27.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.57.10&lt;/td&gt;
&lt;td&gt;5 Nov, 23:17&lt;/td&gt;
&lt;td&gt;7 Nov, 02:23&lt;/td&gt;
&lt;td&gt;27 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.57.11&lt;/td&gt;
&lt;td&gt;6 Nov, 22:16&lt;/td&gt;
&lt;td&gt;8 Nov, 02:23&lt;/td&gt;
&lt;td&gt;28 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.57.12&lt;/td&gt;
&lt;td&gt;9 Nov, 21:56&lt;/td&gt;
&lt;td&gt;11 Nov, 02:17&lt;/td&gt;
&lt;td&gt;28.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.57.13&lt;/td&gt;
&lt;td&gt;11 Nov, 01:45&lt;/td&gt;
&lt;td&gt;not there at 09:27&lt;/td&gt;
&lt;td&gt;over 7.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;New files reach the mirror once a day, at about 02:15 UTC, a day or more after they appear on Maven Central. A build whose dependency came out yesterday fails on the mirror alone. We hit that on 22 October: &lt;code&gt;purchases_flutter&lt;/code&gt; 10.18.1 needed &lt;code&gt;com.revenuecat.purchases:purchases-hybrid-common:19.15.0&lt;/code&gt;, the mirror answered 404, and 10 jobs failed with &lt;code&gt;Could not find&lt;/code&gt;. So the mirror goes first, and Maven Central stays last for what the mirror doesn't have yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The init script: mirror first, Maven Central last
&lt;/h2&gt;

&lt;p&gt;Put this file into &lt;code&gt;~/.gradle/init.d/&lt;/code&gt; on the runner, and Gradle applies it to every build there, whatever the build declares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Takes what Gradle would download from Maven Central from Google's mirror,&lt;/span&gt;
&lt;span class="c1"&gt;// and from Maven Central only what the mirror does not have yet.&lt;/span&gt;

&lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;central&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;~/^&lt;/span&gt;&lt;span class="n"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maven&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apache&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;repo1&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maven&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;maven2&lt;/span&gt;\&lt;span class="o"&gt;/?/&lt;/span&gt;
&lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mirror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://maven-central.storage-download.googleapis.com/maven2/'&lt;/span&gt;
&lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'mavenCentralBehindMirror'&lt;/span&gt;

&lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;addFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;RepositoryHandler&lt;/span&gt; &lt;span class="n"&gt;repositories&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maven&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://repo.maven.apache.org/maven2/'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;toMirror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;RepositoryHandler&lt;/span&gt; &lt;span class="n"&gt;repositories&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hasFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="c1"&gt;// all, not configureEach: a configureEach action may not add or move a repository.&lt;/span&gt;
    &lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&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;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fallback&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="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;repository&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;MavenArtifactRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&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="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;url&lt;/span&gt; &lt;span class="o"&gt;=~&lt;/span&gt; &lt;span class="n"&gt;central&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 place, so the order of the repositories stays.&lt;/span&gt;
                &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;central&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mirror&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;hasFallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;hasFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                    &lt;span class="nf"&gt;addFallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repositories&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="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Keep Maven Central last when the build adds a repository later.&lt;/span&gt;
        &lt;span class="kd"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fallback&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;hasFallback&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&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;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&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;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&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;beforeSettings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;toMirror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buildscript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;toMirror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pluginManagement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;toMirror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependencyResolutionManagement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;settingsEvaluated&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;// A build without plugin repositories goes to the Gradle Plugin Portal,&lt;/span&gt;
    &lt;span class="c1"&gt;// which redirects to Maven Central whatever it does not host itself.&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pluginManagement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pluginManagement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repositories&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;maven&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;addFallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;gradlePluginPortal&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="n"&gt;allprojects&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;toMirror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buildscript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;toMirror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repositories&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;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It finds Maven Central by its URL, not by the name of the repository, so &lt;code&gt;mavenCentral()&lt;/code&gt; and &lt;code&gt;maven { url = "https://repo1.maven.org/maven2/" }&lt;/code&gt; both count. It changes the URL in place, so &lt;code&gt;google()&lt;/code&gt; and the other repositories keep their order.&lt;/li&gt;
&lt;li&gt;Every list of repositories in which it changed one gets Maven Central itself at the end. Gradle asks the repositories of a list in order and moves on when one doesn't have a module, so Maven Central gets only what nothing before it has. The script keeps it last when the build adds a repository later, as the Gradle plugin of Flutter does with its own storage.&lt;/li&gt;
&lt;li&gt;It covers the settings, the build scripts and the projects of every build, including included builds such as the one of the Gradle plugin of Flutter.&lt;/li&gt;
&lt;li&gt;A build that declares no plugin repositories gets the mirror and Maven Central before the Gradle Plugin Portal. The next section explains why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow puts the file into place before the first step that runs Gradle:&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="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Take what Gradle downloads from Maven Central from its mirror&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;mkdir -p ~/.gradle/init.d&lt;/span&gt;
    &lt;span class="s"&gt;cp .github/gradle/maven_central_mirror.gradle ~/.gradle/init.d/&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your job sets &lt;code&gt;GRADLE_USER_HOME&lt;/code&gt;, the script goes into &lt;code&gt;init.d&lt;/code&gt; there. &lt;code&gt;gradle/actions/setup-gradle&lt;/code&gt; adds an init script of its own to the same directory, and Gradle runs both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gradle Plugin Portal sends you back to Maven Central
&lt;/h2&gt;

&lt;p&gt;A mirror for &lt;code&gt;mavenCentral()&lt;/code&gt; isn't enough. Since &lt;a href="https://blog.gradle.org/portal-jcenter-impact" rel="noopener noreferrer"&gt;July 2024&lt;/a&gt;, the Gradle Plugin Portal answers &lt;code&gt;303 See Other&lt;/code&gt; with an address on &lt;code&gt;repo.maven.apache.org&lt;/code&gt; for every artifact it doesn't host itself. The portal serves the jar of the Kotlin Gradle plugin, but not the plugin's dependencies: &lt;code&gt;kotlin-gradle-plugin-api&lt;/code&gt;, &lt;code&gt;kotlin-stdlib&lt;/code&gt;, &lt;code&gt;kotlinx-coroutines-core-jvm&lt;/code&gt;, Gson and others. Gradle follows the redirect without a word, so the output only shows &lt;code&gt;plugins.gradle.org&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the Flutter app, 60 files reached Maven Central this way. A build that declares no plugin repositories in &lt;code&gt;pluginManagement&lt;/code&gt;, such as the included build of the Gradle plugin of Flutter, uses the portal without listing it, so no rewrite of a URL reaches it. That's what the &lt;code&gt;settingsEvaluated&lt;/code&gt; block is for: the mirror and Maven Central go in front of the portal, and the portal only gets the plugin markers and jars that it hosts itself.&lt;/p&gt;

&lt;p&gt;Don't point the portal at the mirror instead. Google's mirror has only Maven Central, and the build fails on the first plugin it looks up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gradle 9.8 and the init script of the Gradle blog
&lt;/h2&gt;

&lt;p&gt;Gradle has two answers of its own, and neither handles the lag of the mirror or the portal.&lt;/p&gt;

&lt;p&gt;The Gradle blog post &lt;a href="https://blog.gradle.org/maven-central-mirror" rel="noopener noreferrer"&gt;Good Neighbors&lt;/a&gt; (August 2025) has an init script that points the repositories named &lt;code&gt;MavenRepo&lt;/code&gt; and &lt;code&gt;Gradle Central Plugin Repository&lt;/code&gt; at one URL. It is written for an internal proxy that serves both Maven Central and the Plugin Portal. With Google's mirror as that URL, the Flutter build fails right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plugin [id: 'org.gradle.kotlin.kotlin-dsl', version: '6.2.0'] was not found in any of the following sources:
...
    Gradle Central Plugin Repository(https://maven-central.storage-download.googleapis.com/maven2/)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I let it change only &lt;code&gt;MavenRepo&lt;/code&gt;, the build worked, but the 60 redirects through the portal stayed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.gradle.org/current/userguide/centralizing_repositories.html" rel="noopener noreferrer"&gt;Gradle 9.8.0&lt;/a&gt;, released on 24 September 2026, can read &lt;code&gt;&amp;lt;mirror&amp;gt;&lt;/code&gt; entries from Maven's &lt;code&gt;~/.m2/settings.xml&lt;/code&gt; when &lt;code&gt;org.gradle.mirror.maven.settings=true&lt;/code&gt; is set. The feature is incubating. With &lt;code&gt;&amp;lt;mirrorOf&amp;gt;central&amp;lt;/mirrorOf&amp;gt;&lt;/code&gt; pointing at Google's mirror, Gradle 9.9.1 took nothing directly from Maven Central, but 52 files still came from there through the portal. And a version published that morning, &lt;code&gt;org.jetbrains.kotlin:kotlin-stdlib:2.5.0-RC&lt;/code&gt;, failed to resolve, since a Maven mirror replaces the repository and leaves no fallback. A new Flutter app doesn't have the feature until you update its Gradle wrapper: &lt;code&gt;flutter create&lt;/code&gt; writes Gradle 9.1.0 with Flutter 3.44 and 9.3.1 with 3.47.&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;Directly from Maven Central&lt;/th&gt;
&lt;th&gt;Through the Plugin Portal&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing, Gradle 9.1.0&lt;/td&gt;
&lt;td&gt;332 requests&lt;/td&gt;
&lt;td&gt;60 files&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradle blog script, Google's mirror&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;fails on the first plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradle 9.9.1, mirror from &lt;code&gt;settings.xml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;52 files&lt;/td&gt;
&lt;td&gt;works, no fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The script above&lt;/td&gt;
&lt;td&gt;0 downloads&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;works, with fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Check that nothing reaches Maven Central
&lt;/h2&gt;

&lt;p&gt;Run the build once with an empty Gradle home and the script in it, then count the hosts:&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="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;GRADLE_USER_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(mktemp -d)"&lt;/span&gt;
mkdir -p &lt;span class="s2"&gt;"$GRADLE_USER_HOME/init.d"&lt;/span&gt;
cp maven_central_mirror.gradle &lt;span class="s2"&gt;"$GRADLE_USER_HOME/init.d/"&lt;/span&gt;
./gradlew --debug assembleDebug 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 \
  &lt;span class="o"&gt;|&lt;/span&gt; grep -oE &lt;span class="s2"&gt;"Performing HTTP (GET|HEAD): https://[^/]+|Redirect requested to location 'https://[^/]+"&lt;/span&gt; \
  &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; uniq -c &lt;span class="o"&gt;|&lt;/span&gt; sort -rn

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Redirect&lt;/code&gt; lines matter: without them, the portal's trips to Maven Central don't show. For the Flutter app, the output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 387 Performing HTTP GET: https://maven-central.storage-download.googleapis.com
 380 Performing HTTP GET: https://dl.google.com
  14 Performing HTTP HEAD: https://dl.google.com
   8 Performing HTTP GET: https://storage.googleapis.com
   4 Performing HTTP GET: https://plugins.gradle.org
   3 Redirect requested to location 'https://plugins-artifacts.gradle.org
   2 Performing HTTP HEAD: https://maven-central.storage-download.googleapis.com
   2 Performing HTTP GET: https://repo.maven.apache.org
   1 Performing HTTP HEAD: https://plugins.gradle.org

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No redirect leads to Maven Central. The two requests to &lt;code&gt;repo.maven.apache.org&lt;/code&gt; are 404s, not downloads: the &lt;code&gt;org.gradle.kotlin.kotlin-dsl&lt;/code&gt; plugin and its marker are neither on the mirror nor on Maven Central, so Gradle finds them on the portal, which comes next in the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF keeps its CI off Maven Central
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt; is a free, open-source Flutter project generator, and its CI generates and builds apps from combinations of its modules for pull requests and every night. Every job that runs Gradle on Linux first puts &lt;a href="https://github.com/saymyframe/smf_flutter_cli/blob/main/.github/gradle/maven_central_mirror.gradle" rel="noopener noreferrer"&gt;the same script&lt;/a&gt; into &lt;code&gt;~/.gradle/init.d&lt;/code&gt;, and a test of the workflows fails when a new job forgets it. From 2 October to 11 November 2026, that was at least 3,917 Android jobs with no 403 and no 429. The one failure from a Maven repository in that time was the lag of the mirror on 22 October, and that's why Maven Central now stays last. Since the &lt;a href="https://saymyframe.com/blog/shared-preferences-android-release-build-crash" rel="noopener noreferrer"&gt;shared_preferences release build crash&lt;/a&gt;, which no debug build shows, the same CI also starts a generated app as a release build on an emulator.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ci</category>
      <category>android</category>
      <category>dart</category>
    </item>
    <item>
      <title>Fix "APNS token has not been set yet" in Flutter on iOS</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/fix-apns-token-has-not-been-set-yet-in-flutter-on-ios-1mlp</link>
      <guid>https://dev.to/ybeshkarov/fix-apns-token-has-not-been-set-yet-in-flutter-on-ios-1mlp</guid>
      <description>&lt;p&gt;On iOS, Firebase delivers push notifications through Apple's push service (APNs), so &lt;code&gt;FirebaseMessaging.instance.getToken()&lt;/code&gt; refuses to run until Apple has given the device its APNs token. Called earlier, it throws &lt;code&gt;apns-token-not-set&lt;/code&gt;, which older versions of &lt;code&gt;firebase_messaging&lt;/code&gt; describe as "APNS token has not been set yet". Usually the app just asked too early. How it should wait depends on the version: &lt;code&gt;firebase_messaging&lt;/code&gt; 17 tells the app when Firebase is ready, and 16.7 doesn't. In a few setups, though, Apple never answers, however long the app waits.&lt;/p&gt;

&lt;p&gt;I checked everything below on an iPhone 17 simulator with iOS 26.5, Xcode 27.2 and Flutter 3.44.2, with &lt;code&gt;firebase_messaging&lt;/code&gt; 16.7.1 (Firebase iOS SDK 12.19.3) and 17.0.1 (Firebase iOS SDK 13.1.1).&lt;/p&gt;

&lt;h2&gt;
  
  
  APNS token has not been set yet, or has not been received on the device yet
&lt;/h2&gt;

&lt;p&gt;The error comes in two wordings. Older versions say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[firebase_messaging/apns-token-not-set] APNS token has not been set yet. Please ensure the APNS token is available by calling `getAPNSToken()`.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since October 2025 (&lt;code&gt;firebase_messaging&lt;/code&gt; 16.0.3, with &lt;code&gt;firebase_messaging_platform_interface&lt;/code&gt; 4.7.3), the same error says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[firebase_messaging/apns-token-not-set] APNS token has not been received on the device yet. Please ensure the APNS token is available before calling `getAPNSToken()`.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are a &lt;code&gt;FirebaseException&lt;/code&gt; with the code &lt;code&gt;apns-token-not-set&lt;/code&gt;, so check the code rather than the text. The hint to call &lt;code&gt;getAPNSToken()&lt;/code&gt; is misleading, because that call never throws. It returns &lt;code&gt;null&lt;/code&gt; until Apple has answered, which makes it the thing to wait on.&lt;/p&gt;

&lt;p&gt;The check runs in Dart, before anything reaches iOS. &lt;code&gt;getToken()&lt;/code&gt;, &lt;code&gt;deleteToken()&lt;/code&gt;, &lt;code&gt;subscribeToTopic()&lt;/code&gt; and &lt;code&gt;unsubscribeFromTopic()&lt;/code&gt; all start with it. So a topic subscription at startup fails with the same error, while &lt;code&gt;requestPermission()&lt;/code&gt; and &lt;code&gt;onMessage&lt;/code&gt; work. If you use Firebase from Swift, the native SDK reports the same situation as &lt;code&gt;No APNS token specified before fetching FCM Token&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why getToken() fails right after the app starts
&lt;/h2&gt;

&lt;p&gt;At launch, the plugin asks iOS to register the app for remote notifications, and iOS answers later, in &lt;code&gt;application:didRegisterForRemoteNotificationsWithDeviceToken:&lt;/code&gt;. The Firebase iOS SDK keeps that answer in memory only. So an early &lt;code&gt;getToken()&lt;/code&gt; fails on every launch, not just the first one, even when Firebase gave the app its FCM token on the previous launch.&lt;/p&gt;

&lt;p&gt;How long the answer takes varies: about half a second on a simulator that had been running for a while, and about 30 seconds right after the simulator booted. On a phone, it depends on the network.&lt;/p&gt;

&lt;p&gt;I checked this with a small app that calls &lt;code&gt;getToken()&lt;/code&gt; right after &lt;code&gt;Firebase.initializeApp()&lt;/code&gt;, after a fresh install and after a restart, with both versions. The call failed every time, although Apple answered within a second. What differs between the versions is whether the app learns that Firebase is ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  firebase_messaging 16: wait for getAPNSToken()
&lt;/h2&gt;

&lt;p&gt;With 16.7.1, the Firebase iOS SDK requests the FCM token by itself as soon as Apple answers (the log shows the app connecting to &lt;code&gt;fcmtoken.googleapis.com&lt;/code&gt;), but the plugin doesn't pass it on to Dart: &lt;code&gt;onTokenRefresh&lt;/code&gt; stayed silent for the 20 seconds I waited, after a fresh install and after a restart. So the app has to wait for &lt;code&gt;getAPNSToken()&lt;/code&gt; itself, then call &lt;code&gt;getToken()&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="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:firebase_messaging/firebase_messaging.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/foundation.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;/// The FCM token of this device once Apple has answered, or `null` when&lt;/span&gt;
&lt;span class="c1"&gt;/// Apple hasn't within [limit].&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pushTokenWhenReady&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;limit&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;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;minutes:&lt;/span&gt; &lt;span class="mi"&gt;2&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;messaging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FirebaseMessaging&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="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;kIsWeb&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;defaultTargetPlatform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;iOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wait&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;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;waited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;messaging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAPNSToken&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waited&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;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;waited&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;wait&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;wait&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="kd"&gt;const&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;seconds:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;wait&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="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;messaging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&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;Don't await it before &lt;code&gt;runApp()&lt;/code&gt;. I found apps on GitHub that opened to a white screen on iOS for exactly this reason: &lt;code&gt;main()&lt;/code&gt; waited, &lt;code&gt;getToken()&lt;/code&gt; threw, and &lt;code&gt;runApp()&lt;/code&gt; never ran. Start the app first and send the result to your server when it arrives:&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;main&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;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&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;Firebase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;DefaultFirebaseOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentPlatform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;runApp&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;MyApp&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;token&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;pushTokenWhenReady&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;token&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sendTokenToServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&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;If the wait runs out, try again when the user comes back to the app, for example from an &lt;code&gt;AppLifecycleListener&lt;/code&gt; with &lt;code&gt;onResume&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  firebase_messaging 17: listen to onTokenRefresh
&lt;/h2&gt;

&lt;p&gt;Since 17.0.0, released in October 2026, the plugin becomes the delegate of the Firebase iOS SDK during &lt;code&gt;Firebase.initializeApp()&lt;/code&gt;, so &lt;code&gt;onTokenRefresh&lt;/code&gt; gets two events that 16 lost. After a fresh install, it fires as soon as the SDK has the FCM token, in my runs 1.4 seconds after Apple answered. On every later launch, it fires right at startup with the value that the SDK saved last time: 0.2 seconds after &lt;code&gt;main()&lt;/code&gt; started, before Apple had answered and while &lt;code&gt;getToken()&lt;/code&gt; still threw.&lt;/p&gt;

&lt;p&gt;So on 17, subscribe to &lt;code&gt;onTokenRefresh&lt;/code&gt; right after &lt;code&gt;initializeApp()&lt;/code&gt;, and keep the &lt;code&gt;getToken()&lt;/code&gt; call for Android, where it doesn't depend on Apple:&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;main&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;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&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;Firebase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;DefaultFirebaseOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentPlatform&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;messaging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FirebaseMessaging&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;messaging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onTokenRefresh&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;sendTokenToServer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;runApp&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;MyApp&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;token&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;messaging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&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;token&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sendTokenToServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&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;FirebaseException&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="c1"&gt;// On iOS before Apple has answered: onTokenRefresh delivers it.&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;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;'apns-token-not-set'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;rethrow&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;Your server may get the same value twice this way, so it should accept one that it already has. Subscribe early, because &lt;code&gt;onTokenRefresh&lt;/code&gt; is a broadcast stream and a listener that comes after the startup event never sees it. If your code subscribes later, after a sign-in for example, wait for &lt;code&gt;getAPNSToken()&lt;/code&gt; as with 16.&lt;/p&gt;

&lt;p&gt;Firebase is also moving Cloud Messaging from registration tokens to the Firebase Installation ID (FID). Version 17 deprecates &lt;code&gt;getToken()&lt;/code&gt;, &lt;code&gt;onTokenRefresh&lt;/code&gt; and &lt;code&gt;deleteToken()&lt;/code&gt; in favor of &lt;code&gt;register()&lt;/code&gt;, &lt;code&gt;onRegistered&lt;/code&gt; and &lt;code&gt;unregister()&lt;/code&gt;, and &lt;code&gt;flutter analyze&lt;/code&gt; exits with code 1 on the code above because of the deprecation infos. I haven't tried the FID path yet, so this article stays with &lt;code&gt;getToken()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the APNs token never comes
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;getAPNSToken()&lt;/code&gt; still returns &lt;code&gt;null&lt;/code&gt; after a minute, waiting longer won't help. These are the cases I know of.&lt;/p&gt;

&lt;h3&gt;
  
  
  No aps-environment entitlement
&lt;/h3&gt;

&lt;p&gt;iOS registers an app for remote notifications only when the build is signed with the &lt;code&gt;aps-environment&lt;/code&gt; entitlement, which Xcode adds with the Push Notifications capability in Signing &amp;amp; Capabilities. Without it, the Xcode console shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FirebaseMessaging][I-FCM012002] Error in application:didFailToRegisterForRemoteNotificationsWithError: no valid "aps-environment" entitlement string found

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The simulator follows the same rule. A build made with &lt;code&gt;flutter build ios --simulator --no-codesign&lt;/code&gt; doesn't get the entitlement, and mine waited two minutes for Apple in vain.&lt;/p&gt;

&lt;h3&gt;
  
  
  A free Apple team
&lt;/h3&gt;

&lt;p&gt;A personal team can't sign &lt;code&gt;aps-environment&lt;/code&gt;. To run the app on an iPhone, you have to remove the capability, and then Apple never answers there. The simulator doesn't need a team, since Xcode signs simulator builds ad hoc.&lt;/p&gt;

&lt;h3&gt;
  
  
  The iOS simulator on GitHub Actions
&lt;/h3&gt;

&lt;p&gt;The iOS simulator on a Mac gets a real APNs token. On a macOS virtual machine it never does, and the macOS runners of GitHub Actions are virtual machines. I tried a &lt;code&gt;macos-26&lt;/code&gt; runner: the app was built with the entitlement, nothing came in four minutes, and the push daemon logged &lt;code&gt;no token info found in keychain&lt;/code&gt; before it disconnected. &lt;a href="https://github.com/actions/runner-images/issues/12747" rel="noopener noreferrer"&gt;actions/runner-images#12747&lt;/a&gt; reports the same for macos-15. A CI job on a hosted runner can check that the app starts without push notifications, but not that it gets them.&lt;/p&gt;

&lt;h3&gt;
  
  
  firebase_messaging 16.5.0 and 16.6.0
&lt;/h3&gt;

&lt;p&gt;These two versions skipped the registration for remote notifications at launch in two common setups: when Firebase is initialized from Dart, as with &lt;code&gt;Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform)&lt;/code&gt; (&lt;a href="https://github.com/firebase/flutterfire/issues/18643" rel="noopener noreferrer"&gt;flutterfire#18643&lt;/a&gt;), and in apps with scenes (&lt;a href="https://github.com/firebase/flutterfire/issues/18555" rel="noopener noreferrer"&gt;flutterfire#18555&lt;/a&gt;), which the iOS template of current Flutter creates with &lt;code&gt;SceneDelegate.swift&lt;/code&gt;. &lt;code&gt;getAPNSToken()&lt;/code&gt; then stayed &lt;code&gt;null&lt;/code&gt; on every launch. Version 16.7.0 fixed both. Check which version your app has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub deps | &lt;span class="nb"&gt;grep &lt;/span&gt;firebase_messaging

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Auto-init is off
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;FirebaseMessagingAutoInitEnabled&lt;/code&gt; set to &lt;code&gt;NO&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt;, or after &lt;code&gt;setAutoInitEnabled(false)&lt;/code&gt;, the plugin doesn't register for remote notifications. Call &lt;code&gt;setAutoInitEnabled(true)&lt;/code&gt; before you start waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not the APNs key
&lt;/h3&gt;

&lt;p&gt;The APNs key in the Firebase console doesn't belong on this list. Firebase needs it to send messages through Apple, so without it nothing arrives, but &lt;code&gt;getAPNSToken()&lt;/code&gt; and &lt;code&gt;getToken()&lt;/code&gt; work all the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF handles push notifications on iOS
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://doc.saymyframe.com/modules/firebase-messaging" rel="noopener noreferrer"&gt;firebase_messaging module&lt;/a&gt; of &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt;, a free, open-source Flutter project generator, gives the app the address of the device as &lt;code&gt;appPush.address&lt;/code&gt;, which stays &lt;code&gt;null&lt;/code&gt; until Firebase has one. The first frame never waits for it. On iOS, the module polls &lt;code&gt;getAPNSToken()&lt;/code&gt; as above, waiting from half a second up to eight seconds between tries, for at most two minutes, and asks again whenever the user comes back to the app. It adds &lt;code&gt;aps-environment&lt;/code&gt; and the background mode &lt;code&gt;remote-notification&lt;/code&gt;, and the README of the app explains what to do with a free team. Uploading the APNs key stays a &lt;a href="https://doc.saymyframe.com/setup/firebase-messaging-apns-key" rel="noopener noreferrer"&gt;step you do by hand&lt;/a&gt;. SMF's CI starts the apps on the iOS simulator of a hosted runner, where they have to start and work without an address.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ios</category>
      <category>firebase</category>
      <category>dart</category>
    </item>
    <item>
      <title>SMF 0.8: Flutter flavors, CI and a REST client</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-08-flutter-flavors-ci-and-a-rest-client-4fbm</link>
      <guid>https://dev.to/ybeshkarov/smf-08-flutter-flavors-ci-and-a-rest-client-4fbm</guid>
      <description>&lt;p&gt;Say My Frame (SMF) generates Flutter apps from independent modules. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.8 is out on pub.dev&lt;/a&gt;, and it covers what an app needs before it goes to the stores: Flutter flavors, CI, a REST client, and an icon and a splash screen that are not the ones of Flutter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;-m&lt;/span&gt; home,bottom_tabs,material_theme,gen_l10n,dio,github_actions,launcher_icons,native_splash

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-8-flutter-flavors%2Fsmf-create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-8-flutter-flavors%2Fsmf-create.gif" alt="smf create in a terminal: it asks for the app name and the modules, among them the REST client, the app icon, the splash screen and GitHub Actions, adds the modules that the chosen ones need, makes the app icon and the splash screen, and generates a Flutter app" width="600" height="668"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As before, you get a regular Flutter project with no SMF package in its dependencies, and SMF never touches it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flutter flavors in every app
&lt;/h2&gt;

&lt;p&gt;Every app now has &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt; and &lt;code&gt;prod&lt;/code&gt;. You don't ask for them, and no package stands behind them: they are plain Gradle and Xcode settings. Each flavor has its own id and its own name, so all three sit next to each other on one phone.&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%2Fzswcaelfbb8zu4orv3py.jpg" 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%2Fzswcaelfbb8zu4orv3py.jpg" alt="The home screen of an iPhone simulator with three apps of one project: My App, My App Dev and My App Staging, each with the default icon of SMF" width="800" height="776"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;flutter run&lt;/code&gt; builds &lt;code&gt;dev&lt;/code&gt;. So does any other &lt;code&gt;flutter&lt;/code&gt; command without &lt;code&gt;--flavor&lt;/code&gt;, in release mode too, which means &lt;code&gt;flutter build appbundle&lt;/code&gt; gives you a release of your dev app. A build for a store always names the flavor. In an app with values in &lt;code&gt;.env&lt;/code&gt;, it takes those too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter build appbundle &lt;span class="nt"&gt;--flavor&lt;/span&gt; prod &lt;span class="nt"&gt;--dart-define-from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;firebase_core&lt;/code&gt;, each flavor has its own Firebase apps, in one project or in several. In a terminal, &lt;code&gt;smf create&lt;/code&gt; configures &lt;code&gt;dev&lt;/code&gt;, and each other flavor takes one command:&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;dart&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;configure_firebase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dart&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;my&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;staging&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; is the one thing that does not follow the flavor. There is one file, and a command takes what the file has when it runs. On your machine you change the values by hand, and in CI the job writes the file for the environment that it builds. &lt;a href="https://doc.saymyframe.com/guides/flavors" rel="noopener noreferrer"&gt;The guide to flavors&lt;/a&gt; has the rest, such as adding a fourth flavor.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI on GitHub Actions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;github_actions&lt;/code&gt; adds &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;. Each push to a branch checks the format and runs the analyzer and the tests, with nothing to set up first.&lt;/p&gt;

&lt;p&gt;A build of the Android app runs on request, for the environment that you pick, with the values of &lt;code&gt;.env&lt;/code&gt; from the variables of that environment on GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh workflow run ci.yml &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://doc.saymyframe.com/guides/ci" rel="noopener noreferrer"&gt;The CI guide&lt;/a&gt; lists the commands and where the variables go.&lt;/p&gt;

&lt;h2&gt;
  
  
  A REST client with its address in .env
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dio&lt;/code&gt; gives the app &lt;code&gt;appRest&lt;/code&gt;, a client that reads the address of your API from &lt;code&gt;REST_BASE_URL&lt;/code&gt; in &lt;code&gt;.env&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;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;appRest&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="s"&gt;'notes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;query:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'page'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No screen of the app makes a request on its own, since the client is there for the code that you add. What it does for that code is the part I didn't want to leave to each app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt; repeat after a connection that failed and after 429, 502, 503 and 504. Other methods never repeat, since a repeated &lt;code&gt;POST&lt;/code&gt; may do its work twice.&lt;/li&gt;
&lt;li&gt;In an app with sign-in, the client adds the token of the user, gets a new one once after a 401, and takes the token away on a redirect to another origin.&lt;/li&gt;
&lt;li&gt;A call fails only with a &lt;code&gt;RestFailure&lt;/code&gt;, which has a closed set of reasons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://doc.saymyframe.com/guides/rest" rel="noopener noreferrer"&gt;The REST guide&lt;/a&gt; covers the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  An app icon and a splash screen
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;launcher_icons&lt;/code&gt; and &lt;code&gt;native_splash&lt;/code&gt; replace the logo of Flutter and the white launch screen with the mark of SMF, and the splash screen has a variant for the dark mode of the device. To use your own, replace the images in &lt;code&gt;assets/branding/&lt;/code&gt; and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dart tool/update_app_icon.dart
dart run flutter_native_splash:create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://doc.saymyframe.com/guides/app-icon-and-splash" rel="noopener noreferrer"&gt;The guide to the icon and the splash screen&lt;/a&gt; gives the sizes of the images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.7
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apps generated by 0.7 stay as they are, since SMF generates an app once.&lt;/li&gt;
&lt;li&gt;If you write modules, &lt;code&gt;smf_contracts&lt;/code&gt; changed: a provider of the app entry writes the flavors into its native projects, and a provider of sign-in implements &lt;code&gt;userToken&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A module that reads &lt;code&gt;lib/firebase_options.dart&lt;/code&gt; needs an update, since each flavor now has &lt;code&gt;lib/firebase_options_&amp;lt;flavor&amp;gt;.dart&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.8.0" rel="noopener noreferrer"&gt;release notes of SMF 0.8.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;Next on my list are the rating of the app in the stores, reviews, remote config and ads. There are no dates: SMF is a side project. If you have a module in mind, tell me on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;SMF generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ci</category>
      <category>firebase</category>
      <category>dart</category>
    </item>
    <item>
      <title>Fix Google Sign-In [28444] in Flutter with google_sign_in 7</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/fix-google-sign-in-28444-in-flutter-with-googlesignin-7-34c</link>
      <guid>https://dev.to/ybeshkarov/fix-google-sign-in-28444-in-flutter-with-googlesignin-7-34c</guid>
      <description>&lt;p&gt;Since version 7, &lt;code&gt;google_sign_in&lt;/code&gt; signs users in on Android through Credential Manager, and a broken setup shows up as one of a few messages. The best known is &lt;code&gt;[28444] Developer console is not set up correctly&lt;/code&gt;, and the checklists for it usually start with the SHA-1. I reproduced each case on an emulator, and with current Google Play services a missing SHA-1 doesn't give the app this error at all. The app gets a &lt;code&gt;canceled&lt;/code&gt; with &lt;code&gt;Account reauth failed&lt;/code&gt; instead. 28444 showed up when the app sent the wrong client ID.&lt;/p&gt;

&lt;p&gt;Everything below was checked on an Android 17 emulator with Google Play services 26.41, &lt;code&gt;google_sign_in&lt;/code&gt; 7.2.0 and &lt;code&gt;google_sign_in_android&lt;/code&gt; 7.2.18, against a Firebase project. Before version 7, the plugin reported a broken setup as &lt;code&gt;ApiException: 10&lt;/code&gt;, the &lt;code&gt;DEVELOPER_ERROR&lt;/code&gt; of Google Play services.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each Google Sign-In error means
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GoogleSignIn.instance.authenticate()&lt;/code&gt; throws a &lt;code&gt;GoogleSignInException&lt;/code&gt;. The code and the description tell the cases apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;What is wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[28444] Developer console is not set up correctly.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unknownError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google doesn't take the &lt;code&gt;serverClientId&lt;/code&gt; as the web client of the app: it is the ID of the Android client, or a client that doesn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverClientId must be provided on Android&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clientConfigurationError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The app has no web client ID at all: &lt;code&gt;google-services.json&lt;/code&gt; has none, or the release build dropped it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[16] Account reauth failed.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;canceled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No Android OAuth client with the package name of the app and the SHA-1 of the key that signed it, or no connection to Google&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[16] Cancelled by user.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;canceled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The user closed the sheet, or backed out of adding a Google account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two cases of &lt;code&gt;canceled&lt;/code&gt; share a code. The README of the plugin warns about this: it can't tell some setup errors from a user who cancelled. &lt;code&gt;attemptLightweightAuthentication()&lt;/code&gt; hides a missing SHA-1 completely and just returns &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;logcat prints &lt;code&gt;GetCredentialResponse error returned from framework&lt;/code&gt; for every failure, a plain cancel included, so that line doesn't point to the cause. And on a device or an emulator without a Google account, the sign-in never gets far enough to fail: Credential Manager opens the screen that adds an account. Add one before you test the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  See the real cause in logcat
&lt;/h2&gt;

&lt;p&gt;Google Play services logs what its server answered. Filter logcat to its two tags while you try to sign in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb logcat &lt;span class="nt"&gt;-s&lt;/span&gt; Auth:W Auth.Api.Credentials:W

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a missing SHA-1, the first lines are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;W Auth : [GetTokenResponseHandler] Server returned error: This android application is not registered to use OAuth2.0, please confirm the package name and SHA-1 certificate fingerprint match what you registered in Google Developer Console.
W Auth.Api.Credentials: cwsp: [8] Unknown error [status=UNREGISTERED_ON_API_CONSOLE].
W Auth.Api.Credentials: cwsp: [16] Account reauth failed.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a wrong client ID, the same tags show &lt;code&gt;28444: Developer console is not set up correctly&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  [28444] Developer console is not set up correctly: the wrong client ID
&lt;/h2&gt;

&lt;p&gt;To give the app an ID token, Google needs the client ID of a web application OAuth client, which &lt;code&gt;google_sign_in&lt;/code&gt; sends as &lt;code&gt;serverClientId&lt;/code&gt;. When Google doesn't accept that ID, the app gets:&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;GoogleSignInException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="n"&gt;GoogleSignInExceptionCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unknownError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;28444&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Developer&lt;/span&gt; &lt;span class="n"&gt;console&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="n"&gt;correctly&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got it in two ways. The first was passing the ID of the Android client to &lt;code&gt;initialize()&lt;/code&gt;: a Firebase project has both kinds, and they look alike, &lt;code&gt;&amp;lt;number&amp;gt;-&amp;lt;letters&amp;gt;.apps.googleusercontent.com&lt;/code&gt;. The second was an ID that doesn't exist, as with a typo or a client deleted in the Google Cloud console.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;google-services.json&lt;/code&gt;, the web client is the one with &lt;code&gt;"client_type": 3&lt;/code&gt;, and the Android clients have &lt;code&gt;"client_type": 1&lt;/code&gt;. To print the web client ID:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-B1&lt;/span&gt; &lt;span class="s1"&gt;'"client_type": 3'&lt;/span&gt; android/app/google-services.json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; client_id

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you pass the ID yourself, pass that one:&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;GoogleSignIn&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;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;serverClientId:&lt;/span&gt; &lt;span class="s"&gt;'1234567890-abc123.apps.googleusercontent.com'&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;With a &lt;code&gt;google-services.json&lt;/code&gt; that has a web client, you don't need to pass anything: the plugin reads the ID from the resources that the Google services Gradle plugin generates from that file.&lt;/p&gt;

&lt;p&gt;The checklists aren't wrong to mention the SHA-1, though. Inside Google Play services, a missing SHA-1 is 28444 too: logcat shows &lt;code&gt;cwsp: [28444]&lt;/code&gt; when &lt;code&gt;attemptLightweightAuthentication()&lt;/code&gt; runs into one, even though the app only gets &lt;code&gt;null&lt;/code&gt;. I can't rule out that other versions of Play services pass it on to the app. If the client ID is right, check the SHA-1 next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Account reauth failed: the SHA-1 of the signing key is missing
&lt;/h2&gt;

&lt;p&gt;Google recognizes an Android app by its package name and the SHA-1 of the certificate that signed it. With Firebase, adding a SHA fingerprint to the Android app creates the Android OAuth client that Google looks for. When there is none, the user picks an account and the app gets:&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;GoogleSignInException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="n"&gt;GoogleSignInExceptionCode&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt; &lt;span class="n"&gt;reauth&lt;/span&gt; &lt;span class="n"&gt;failed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same message shows up in two other cases. When the device can't reach Google (on the emulator I turned off Wi-Fi and mobile data), logcat shows &lt;code&gt;[7] Network error.&lt;/code&gt; right before it. And in &lt;a href="https://github.com/flutter/flutter/issues/190296" rel="noopener noreferrer"&gt;flutter/flutter#190296&lt;/a&gt;, an app with a correct setup got it on every sign-in after the first one. The reporter fixed it by calling &lt;code&gt;GoogleSignIn.instance.signOut()&lt;/code&gt; on sign-out. I tried that on the same emulator: eight sign-ins in a row without &lt;code&gt;signOut()&lt;/code&gt;, some right after the app's own sign-out, and all of them worked. So it may depend on something that report had and my setup didn't, such as a physical device, an older plugin or an app installed from Google Play. logcat tells the cases apart: a missing SHA-1 shows &lt;code&gt;not registered to use OAuth2.0&lt;/code&gt;, and no network shows &lt;code&gt;Network error&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the SHA-1 of the build that fails
&lt;/h3&gt;

&lt;p&gt;Gradle prints the key that signs each build type, and it is not always the one you expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd android &amp;amp;&amp;amp; ./gradlew signingReport


Variant: release
Config: debug
Store: /Users/you/.android/debug.keystore
Alias: AndroidDebugKey
SHA1: 60:45:3E:2E:7F:82:01:EC:F4:84:F8:43:A2:44:70:F9:4D:F9:7B:F5

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new Flutter app signs its release build with the debug key too (&lt;code&gt;signingConfig = signingConfigs.getByName("debug")&lt;/code&gt; in &lt;code&gt;android/app/build.gradle.kts&lt;/code&gt;), until you give it a key of its own. From then on, the release needs that key's SHA-1.&lt;/p&gt;

&lt;p&gt;For an APK you already have, read the certificate from the file with &lt;code&gt;apksigner&lt;/code&gt; from the Android SDK build tools:&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="nv"&gt;$ANDROID_HOME&lt;/span&gt;/build-tools/&amp;lt;version&amp;gt;/apksigner verify &lt;span class="nt"&gt;--print-certs&lt;/span&gt; build/app/outputs/flutter-apk/app-release.apk

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keytool -printcert -jarfile app.apk&lt;/code&gt; from &lt;a href="https://developers.google.com/android/guides/client-auth" rel="noopener noreferrer"&gt;Google's client authentication guide&lt;/a&gt; prints &lt;code&gt;Not a signed jar file&lt;/code&gt; for a Flutter APK. Flutter sets &lt;code&gt;minSdk&lt;/code&gt; to 24, and from that level on, the APK has only a v2 signature, which &lt;code&gt;keytool&lt;/code&gt; doesn't read.&lt;/p&gt;

&lt;p&gt;Some keys are easy to forget. Every machine has its own &lt;code&gt;~/.android/debug.keystore&lt;/code&gt;, so a teammate's debug build has a different SHA-1, and so does a CI runner that creates a fresh key. Google Play adds two more: your upload key, and the app signing key. With Play App Signing, users install a build that Google signed with its own key, and its SHA-1 is in the Play Console, under Test and release &amp;gt; App integrity &amp;gt; App signing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add the SHA-1 to Firebase
&lt;/h3&gt;

&lt;p&gt;In the Firebase console, open Project settings, pick the Android app under Your apps and click Add fingerprint. Add the SHA-256 too. The Firebase CLI does the same, with the app ID from &lt;code&gt;firebase apps:list ANDROID&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;firebase apps:android:sha:create &amp;lt;appId&amp;gt; &amp;lt;sha1&amp;gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't need to download &lt;code&gt;google-services.json&lt;/code&gt; again for this: the app never reads the Android clients from it. In my test, the same build signed in about 40 seconds after I added the SHA-1, with the old file.&lt;/p&gt;

&lt;p&gt;Every SHA you add creates another Android OAuth client in the project, and Google caps the number of them. Removing a SHA from Firebase doesn't remove its client. Once the project is full, &lt;code&gt;apps:android:sha:create&lt;/code&gt; fails with &lt;code&gt;Failed to create SHA certificate hash&lt;/code&gt;, and only &lt;code&gt;--debug&lt;/code&gt; shows the answer of the API: &lt;code&gt;429 Number of OAuth clients created exceeded limit&lt;/code&gt;. I ran into that because the CI of SMF registered the fresh debug key of every runner. Give CI one debug keystore that doesn't change, and register its SHA-1 once.&lt;/p&gt;

&lt;h2&gt;
  
  
  serverClientId must be provided on Android: no web client in google-services.json
&lt;/h2&gt;

&lt;p&gt;When you don't pass &lt;code&gt;serverClientId&lt;/code&gt;, &lt;code&gt;google_sign_in_android&lt;/code&gt; reads the string resource &lt;code&gt;default_web_client_id&lt;/code&gt;. The Google services Gradle plugin generates it from the web client in &lt;code&gt;android/app/google-services.json&lt;/code&gt;. Without it, &lt;code&gt;authenticate()&lt;/code&gt; fails at once, before any Google screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;GoogleSignInException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="nc"&gt;GoogleSignInExceptionCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientConfigurationError&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverClientId&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;provided&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="nc"&gt;Android&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual cause is a &lt;code&gt;google-services.json&lt;/code&gt; downloaded before Google sign-in was enabled in Firebase Authentication. That file has &lt;code&gt;"oauth_client": []&lt;/code&gt;, and the build gets no &lt;code&gt;default_web_client_id&lt;/code&gt;. Check your file:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'"client_type": 3'&lt;/span&gt; android/app/google-services.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0&lt;/code&gt; means no web client. Enable Google in the Firebase console, under Authentication &amp;gt; Sign-in method, then download the file again, from Project settings or with the Firebase CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firebase apps:sdkconfig ANDROID &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--out&lt;/span&gt; android/app/google-services.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check that &lt;code&gt;android/app/build.gradle.kts&lt;/code&gt; applies the &lt;code&gt;com.google.gms.google-services&lt;/code&gt; plugin, which FlutterFire CLI adds when it configures the app. Without it, nothing generates the resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Works in debug, fails in release
&lt;/h2&gt;

&lt;p&gt;When Google Sign-In works in debug builds and fails in the release, the message tells you which of two things happened.&lt;/p&gt;

&lt;p&gt;If the release fails with &lt;code&gt;[16] Account reauth failed.&lt;/code&gt;, Firebase doesn't know the key of the release: your release key, or, for a build from Google Play, the app signing key of Google Play. Add its SHA-1 as above.&lt;/p&gt;

&lt;p&gt;If it fails with &lt;code&gt;serverClientId must be provided on Android&lt;/code&gt;, the release build lost &lt;code&gt;default_web_client_id&lt;/code&gt;. Flutter shrinks the resources of a release build by default, and the plugin looks the string up by its name at run time, which the shrinker doesn't see. Up to &lt;code&gt;google_sign_in_android&lt;/code&gt; 7.2.15, the shrinker removed it from every app (&lt;a href="https://github.com/flutter/flutter/issues/188363" rel="noopener noreferrer"&gt;flutter/flutter#188363&lt;/a&gt;). Version 7.2.16 added a keep rule in &lt;code&gt;res/raw/keep.xml&lt;/code&gt; of the plugin (&lt;a href="https://github.com/flutter/packages/pull/12075" rel="noopener noreferrer"&gt;flutter/packages#12075&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;That rule has a catch (&lt;a href="https://github.com/flutter/flutter/issues/194943" rel="noopener noreferrer"&gt;flutter/flutter#194943&lt;/a&gt;, open as I write this, with two pull requests that rename the plugin's file under review). If your app has its own &lt;code&gt;android/app/src/main/res/raw/keep.xml&lt;/code&gt;, for example to keep a notification icon, Gradle merges the resources by file name, and the app's file replaces the plugin's. With 7.2.18, the release build then fails with &lt;code&gt;serverClientId must be provided on Android&lt;/code&gt;. The shrinker says why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grep default_web_client_id build/app/outputs/mapping/release/resources.txt


string:default_web_client_id:2131689529 is not reachable.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rename your file, for example to &lt;code&gt;res/raw/app_keep.xml&lt;/code&gt;. The &lt;a href="https://developer.android.com/topic/performance/app-optimization/customize-which-resources-to-keep" rel="noopener noreferrer"&gt;Android guide to keeping resources&lt;/a&gt; asks for unique names for exactly this reason, and the shrinker reads every keep file in &lt;code&gt;res/raw&lt;/code&gt;. After the rename, the same line says &lt;code&gt;reachable from keep xml file&lt;/code&gt;, and sign-in works. You can also add &lt;code&gt;@string/default_web_client_id&lt;/code&gt; to the &lt;code&gt;tools:keep&lt;/code&gt; of your own file. Either way, check that the app has 7.2.16 or later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub deps | &lt;span class="nb"&gt;grep &lt;/span&gt;google_sign_in_android

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CI that builds only debug never sees either case. The same thing happened with &lt;a href="https://saymyframe.com/blog/shared-preferences-android-release-build-crash" rel="noopener noreferrer"&gt;the shared_preferences release build crash&lt;/a&gt;, where R8 broke a plugin only in release builds, so start a release build on an emulator before your users do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF sets up Google Sign-In with Firebase
&lt;/h2&gt;

&lt;p&gt;I went through all of this while building the &lt;a href="https://doc.saymyframe.com/modules/google-sign-in" rel="noopener noreferrer"&gt;google_sign_in module&lt;/a&gt; of &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt;, a free, open-source Flutter project generator. In an app with the &lt;a href="https://doc.saymyframe.com/modules/firebase-auth" rel="noopener noreferrer"&gt;firebase_auth module&lt;/a&gt;, SMF generates a script, &lt;code&gt;tool/enable_firebase_sign_in.dart&lt;/code&gt;, and runs it after &lt;code&gt;flutterfire configure&lt;/code&gt; once you agree. The script enables Google in the Firebase project, registers the SHA-1 and SHA-256 of the debug key of your machine, downloads &lt;code&gt;google-services.json&lt;/code&gt; again with the web client in it, and writes the URL scheme of the iOS app. So a debug build signs in with Google from the first run, and a teammate runs the script once on their own machine. The keys of a release stay a &lt;a href="https://doc.saymyframe.com/setup/firebase-auth-google-release-sha" rel="noopener noreferrer"&gt;step you do by hand&lt;/a&gt;, with the commands in the README of the app.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>android</category>
      <category>firebase</category>
    </item>
    <item>
      <title>SMF 0.7: a Flutter paywall with RevenueCat</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-07-a-flutter-paywall-with-revenuecat-3bap</link>
      <guid>https://dev.to/ybeshkarov/smf-07-a-flutter-paywall-with-revenuecat-3bap</guid>
      <description>&lt;p&gt;Say My Frame (SMF) generates Flutter apps from independent modules. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.7 is out on pub.dev&lt;/a&gt;. It adds a Flutter paywall that sells premium access with RevenueCat, a subscription screen, Restore purchases on the settings screen, and an onboarding page that offers premium. The RevenueCat keys go into a &lt;code&gt;.env&lt;/code&gt; file that the app reads at build time. At the end of &lt;a href="https://saymyframe.com/blog/smf-0-6-flutter-push-notifications" rel="noopener noreferrer"&gt;the post about 0.6&lt;/a&gt;, subscriptions with a paywall were next on my list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;-m&lt;/span&gt; home,onboarding,sign_in,google_sign_in,apple_sign_in,paywall,local_notifications,bottom_tabs,material_theme,gen_l10n,get_it,bloc

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-7-flutter-paywall%2Fsmf-create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-7-flutter-paywall%2Fsmf-create.gif" alt="smf create in a terminal asks for the app name and the modules, among them the paywall, Google sign-in, Sign in with Apple, the onboarding and the notifications, adds the modules that the chosen ones need, such as go_router, the settings screen, Firebase Authentication and RevenueCat, asks who may use the app without an account, and generates a Flutter app with an onboarding, a start screen, sign-in with email, Google and Apple, a paywall, tabs at the bottom, a theme, two languages, notifications, get_it and BLoC" width="720" height="717"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recording answers the questions of &lt;code&gt;smf create&lt;/code&gt; instead of passing &lt;code&gt;-m&lt;/code&gt;. The paywall needs a provider of purchases and a place for its keys, so SMF adds &lt;code&gt;revenuecat&lt;/code&gt; and &lt;code&gt;dart_define&lt;/code&gt; and says why, as it does for the router and the settings screen.&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%2F4wsyeuboot76891o7nbz.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%2F4wsyeuboot76891o7nbz.png" alt="The app that smf create generates, on a phone: the onboarding, the sign-in with Google, Apple and an email address, the start screen, and the paywall in the dark theme and in Ukrainian" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As before, what you get is a regular Flutter project. It has no SMF package in its dependencies, and SMF never touches it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flutter paywall after smf create
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;paywall&lt;/code&gt; is a feature module with two screens. The paywall at &lt;code&gt;/paywall&lt;/code&gt; shows, from the top:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a card in the colors of Say My Frame, with the app as a cell of the periodic table and Premium above its name, as on the start screen;&lt;/li&gt;
&lt;li&gt;three benefits of premium access;&lt;/li&gt;
&lt;li&gt;a card for each plan of the current offering, with the price that the store gives, its period and its free trial, such as &lt;code&gt;$29.99 / year&lt;/code&gt; and &lt;code&gt;1 week free&lt;/code&gt;. A yearly plan that costs less than twelve months of the monthly one gets the badge Best value, and the paywall selects it;&lt;/li&gt;
&lt;li&gt;the button that buys the selected plan, which says Start free trial, Subscribe or Buy, and below it what the plan costs after its trial;&lt;/li&gt;
&lt;li&gt;the terms of the platform's store, and the links Restore purchases, Terms of Use and Privacy Policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The subscription screen at &lt;code&gt;/paywall/subscription&lt;/code&gt; shows the user's plan, the day it renews, ends or ends its free trial, and a button to the store page where the user cancels it.&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%2Fb3nawi7g9jcmh69syj22.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%2Fb3nawi7g9jcmh69syj22.png" alt="Three screens of the generated app: the paywall with the plans Yearly with the badge Best value and 1 week free, and Monthly, above the button Start free trial; the screen of the subscription with the plan Yearly, the day its trial ends and the button Manage subscription; and the settings screen with the rows Account, Subscription, Language, Theme, Notifications and Restore purchases" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most of what the paywall shows comes from the rules of the App Store and Google Play for a screen that sells a subscription. The full price of each period is the largest text of its card, and a yearly plan never shows its price per month, which Google Play does not allow. A plan with a trial says how long the trial lasts and what comes after it. The paywall tells how to cancel in each store's own words, and on iOS the Terms of Use link goes to Apple's standard license agreement until you give an address of your own. The close button stays in sight whenever there is a page below the paywall to go back to.&lt;/p&gt;

&lt;p&gt;The paywall never navigates after a purchase. The route of the subscription screen asks for premium access, and a router guard stands for it. A tap on Subscription in the settings opens the paywall for a user without premium, and once the purchase goes through, the router closes the paywall and shows the subscription screen, so neither screen has to know about the other.&lt;/p&gt;

&lt;p&gt;In an app with an onboarding, the module adds a page, Unlock premium, after the pages that ask for permissions. Its button shows the paywall over the onboarding, and Not now goes on without it. A user who already has premium doesn't see the page. The settings screen gets two rows: Subscription, which says whether premium is active, and Restore purchases, which shows below its title what it found.&lt;/p&gt;

&lt;p&gt;Like the screens of sign-in, the paywall comes in a variant for BLoC and one for Riverpod, in both themes and both languages of the app. The &lt;a href="https://doc.saymyframe.com/modules/paywall" rel="noopener noreferrer"&gt;paywall page of the docs&lt;/a&gt; has the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  RevenueCat keys in .env, read with --dart-define-from-file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;revenuecat&lt;/code&gt; sells the plans of the default offering in a RevenueCat project, with purchases_flutter. Every plan unlocks the same entitlement, &lt;code&gt;premium&lt;/code&gt;. The app needs the public API keys of the project: one for the App Store, one for Google Play, and one for RevenueCat's Test Store, where you can buy in a debug build before your products are in the stores.&lt;/p&gt;

&lt;p&gt;These keys are the first values in SMF that you fill in after generation, so 0.7 adds a role for them. &lt;code&gt;dart_define&lt;/code&gt; gives the app a &lt;code&gt;.env&lt;/code&gt; file with a line for each key that its modules read. The file stays out of git, and &lt;code&gt;.env.example&lt;/code&gt; with the same keys stays in. The app reads the values with &lt;code&gt;--dart-define-from-file=.env&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;flutter run &lt;span class="nt"&gt;--dart-define-from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An edit of &lt;code&gt;.env&lt;/code&gt; needs no code generation, and a fresh clone of the app builds with the empty values. The VS Code and Android Studio run configurations that SMF generates pass the argument, and in VS Code so do Run and Debug and the Testing view.&lt;/p&gt;

&lt;p&gt;Everything in &lt;code&gt;.env&lt;/code&gt; ends up in the app, where anyone can read it. RevenueCat's public keys are made for that. A secret key isn't, so the app refuses a key that starts with &lt;code&gt;sk_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without a key, the app starts and runs without purchases. The paywall says that the plans are not available right now, and a debug build shows you which key is missing. RevenueCat stops a release build that is configured with a Test Store key, so the app takes that key only in a debug build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whose purchases they are, with sign-in and without
&lt;/h2&gt;

&lt;p&gt;In an app with sign-in, the purchases follow the user of the session. Someone who buys before signing up keeps premium on the new account. After a sign-out, the next user of the device has none of those purchases, and signing in again brings them back. In an app without sign-in, the purchases belong to the device. The &lt;a href="https://doc.saymyframe.com/guides/purchases" rel="noopener noreferrer"&gt;purchases guide&lt;/a&gt; shows how code reads premium access and the plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RevenueCat did on real devices
&lt;/h2&gt;

&lt;p&gt;Before writing the module, I ran purchases_flutter in a generated app on an iPhone simulator and an Android 12 emulator, with no key, with made-up keys, and with calls made in the wrong order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On iOS, any call to RevenueCat before it is configured crashes the app. On Android the same call only throws. So the generated service calls nothing before its configuration has finished, and nothing at all without a key.&lt;/li&gt;
&lt;li&gt;With an empty key, iOS configures anyway and sends requests that fail with 401. Android throws an error whose code is the word &lt;code&gt;error&lt;/code&gt;, and the helper of the plugin that reads error codes throws a &lt;code&gt;FormatException&lt;/code&gt; on it. The service reads such a code itself, so a bad configuration ends up as "not configured" with a hint.&lt;/li&gt;
&lt;li&gt;Logging out an anonymous user, which is who uses a device without sign-in, fails in RevenueCat with code 22. The app counts it as done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests of the modules run purchases_flutter over a stand-in for its platform side, which can't show whether premium access really reaches a running app. So CI checks that as well. It starts the app with every module on an Android emulator and an iOS simulator, with the Test Store key of a RevenueCat project. The app asks CI for premium access, a test grants the entitlement in that project through RevenueCat's REST API, with a secret key that never goes near the app, and the app has to show premium access that ends when the grant does. In the first runs, the whole exchange took 7 to 16 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eighteen roles and twenty-five modules, checked in running apps
&lt;/h2&gt;

&lt;p&gt;0.6 had sixteen roles and twenty-two modules. 0.7 has eighteen and twenty-five. The new roles are the purchases and the values in &lt;code&gt;.env&lt;/code&gt;. The new modules are &lt;code&gt;dart_define&lt;/code&gt;, &lt;code&gt;revenuecat&lt;/code&gt; and &lt;code&gt;paywall&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I wrote this, CI generated 84 apps from the modules of the CLI and ran &lt;code&gt;flutter analyze&lt;/code&gt; and &lt;code&gt;flutter test&lt;/code&gt; in each. It also starts the apps with every module on an Android emulator and an iOS simulator, and grants them premium access in RevenueCat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMF 0.7 can't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;There are no dev and prod environments and no HTTP client yet.&lt;/li&gt;
&lt;li&gt;Firebase is the only provider of sign-in, analytics and crash reporting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;Next on my list is what an app needs before it goes to the stores: dev and prod environments, an HTTP client whose address comes from the environment, CI for the generated app, and an app icon and a splash screen. There are no dates. SMF is a side project, and I'd rather ship things when they work.&lt;/p&gt;

&lt;p&gt;If you have a module in mind, tell me which one on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.6
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In an app with a router, &lt;code&gt;firebase_analytics&lt;/code&gt; turns off the screen views that Firebase logs by itself, so each screen is logged once and the app logs no &lt;code&gt;MainActivity&lt;/code&gt; or &lt;code&gt;FlutterViewController&lt;/code&gt;. A reader of &lt;a href="https://saymyframe.com/blog/go-router-firebase-analytics-screen-tracking" rel="noopener noreferrer"&gt;the post about screen tracking with go_router&lt;/a&gt; pointed this out.&lt;/li&gt;
&lt;li&gt;The README of every generated app says that Say My Frame generated it.&lt;/li&gt;
&lt;li&gt;The module API of &lt;code&gt;smf_contracts&lt;/code&gt; changed: the action of an onboarding step gets the &lt;code&gt;BuildContext&lt;/code&gt; of the onboarding, steps have a new stage &lt;code&gt;offer&lt;/code&gt;, and a provider of the app entry has a socket for &lt;code&gt;.gitignore&lt;/code&gt;. A module that adds onboarding steps or provides the app entry needs an update.&lt;/li&gt;
&lt;li&gt;Apps generated by 0.6 stay as they are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.7.0" rel="noopener noreferrer"&gt;release notes of SMF 0.7.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Flutter paywall with smf create
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMF generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer, and it is tested on macOS, Linux and Windows. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, the &lt;a href="https://doc.saymyframe.com/modules/paywall" rel="noopener noreferrer"&gt;paywall page&lt;/a&gt; covers the screens and the store rules, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Stars there are welcome, but only if you actually like it.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>android</category>
      <category>flutter</category>
      <category>dart</category>
    </item>
    <item>
      <title>Flutter Firebase Analytics screen tracking with go_router</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/flutter-firebase-analytics-screen-tracking-with-gorouter-1iij</link>
      <guid>https://dev.to/ybeshkarov/flutter-firebase-analytics-screen-tracking-with-gorouter-1iij</guid>
      <description>&lt;p&gt;The usual advice for Flutter Firebase Analytics screen tracking is to give &lt;code&gt;FirebaseAnalyticsObserver&lt;/code&gt; to &lt;code&gt;GoRouter&lt;/code&gt;. In an app with tabs, it misses screens. Going back to a tab the user has already opened logs nothing, and neither does closing a page that was opened over the tabs. A &lt;code&gt;go()&lt;/code&gt; to a nested route logs every parent page as well, under names like &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt;. I checked this on go_router 18.1.1 and firebase_analytics 13.0.0, with Flutter 3.44.2 and 3.47.4. A listener on the router delegate logs one screen view for each screen the user sees, in about 30 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What FirebaseAnalyticsObserver logs with go_router tabs
&lt;/h2&gt;

&lt;p&gt;The usual setup:&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;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;observers:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FirebaseAnalyticsObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;analytics:&lt;/span&gt; &lt;span class="n"&gt;FirebaseAnalytics&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="nl"&gt;routes:&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;My test app has two tabs in a &lt;code&gt;StatefulShellRoute&lt;/code&gt;: &lt;code&gt;/home&lt;/code&gt;, which has a child route &lt;code&gt;details&lt;/code&gt;, and &lt;code&gt;/settings&lt;/code&gt;. It also has a &lt;code&gt;/profile&lt;/code&gt; page that opens over the tabs on the root navigator, a nested route &lt;code&gt;/a/b/c&lt;/code&gt;, and &lt;code&gt;/item/:id&lt;/code&gt;, which is named &lt;code&gt;item&lt;/code&gt;. A fake &lt;code&gt;FirebaseAnalytics&lt;/code&gt; recorded every call to &lt;code&gt;logScreenView&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the user does&lt;/th&gt;
&lt;th&gt;What the observer logs&lt;/th&gt;
&lt;th&gt;What it should log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The app starts on Home&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opens the Settings tab for the first time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/settings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goes back to the Home tab&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opens the Settings tab again&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/settings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go('/home/details')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;details&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/details&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goes back to Home&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push('/profile')&lt;/code&gt; over the tabs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/profile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/profile&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goes back from Profile&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go('/a/b/c')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/a/b/c&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go('/item/42')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;item&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;item&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Users move between tabs all the time, so the reports undercount the screens behind the tabs. They also count parent pages that nobody saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why FirebaseAnalyticsObserver misses tab switches
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FirebaseAnalyticsObserver&lt;/code&gt; is a &lt;code&gt;NavigatorObserver&lt;/code&gt;. It logs a screen view when a navigator pushes, replaces or pops a route, and at no other time. Each branch of a &lt;code&gt;StatefulShellRoute&lt;/code&gt; has a navigator of its own, and that navigator stays alive in an &lt;code&gt;IndexedStack&lt;/code&gt; while the user is on another tab. The first visit to a tab builds its navigator, which pushes the first page, so that visit is logged. Going back to the tab only changes which navigator is visible. No navigator pushes or pops anything.&lt;/p&gt;

&lt;p&gt;Closing &lt;code&gt;/profile&lt;/code&gt; pops it from the root navigator, and the observer then logs the route below it. That route is the page of the shell route. go_router names a page after the &lt;code&gt;name&lt;/code&gt; of its route, or after its &lt;code&gt;path&lt;/code&gt; when there is no name. A shell route has neither, so the observer gets &lt;code&gt;null&lt;/code&gt; as the name and logs nothing.&lt;/p&gt;

&lt;p&gt;The same goes for a page that your own &lt;code&gt;pageBuilder&lt;/code&gt; returns. Tabs often use &lt;code&gt;NoTransitionPage(child: ...)&lt;/code&gt;, and that page has no name unless you pass one. With such a page on the Home tab, the observer never logged Home, not even when the app started. Pass &lt;code&gt;name: state.name ?? state.path&lt;/code&gt; to the page, and it is logged like the others.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go('/a/b/c')&lt;/code&gt; builds the whole chain. go_router puts a page on the navigator for each of &lt;code&gt;/a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt;, so that back works, and the navigator reports a push for each of them. The names come from &lt;code&gt;path&lt;/code&gt;, and the &lt;code&gt;path&lt;/code&gt; of a child route is its own segment, not the full location. That is also why &lt;code&gt;details&lt;/code&gt; lost its &lt;code&gt;/home/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't add an observer to each branch
&lt;/h2&gt;

&lt;p&gt;Before go_router 17.0.0, the observers of &lt;code&gt;GoRouter&lt;/code&gt; didn't hear the navigators of shell routes, so guides from that time give each &lt;code&gt;ShellRoute&lt;/code&gt; or &lt;code&gt;StatefulShellBranch&lt;/code&gt; observers of its own. Since 17.0.0, shell routes pass their navigation to the observers of &lt;code&gt;GoRouter&lt;/code&gt; by default, through &lt;code&gt;notifyRootObserver&lt;/code&gt;. With one observer on &lt;code&gt;GoRouter&lt;/code&gt; and another on each branch, every screen in a tab is logged twice. Opening the app on Home and then the Settings tab logged this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home, /home, /settings, /settings

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep only the observer on &lt;code&gt;GoRouter&lt;/code&gt;, or pass &lt;code&gt;notifyRootObserver: false&lt;/code&gt; to the shell route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log screen views from the go_router delegate
&lt;/h2&gt;

&lt;p&gt;The router knows what the user sees, whichever navigator it is in. &lt;code&gt;GoRouter.routerDelegate&lt;/code&gt; notifies its listeners each time its configuration changes, and switching tabs counts. Its &lt;code&gt;currentConfiguration&lt;/code&gt; ends with the page on top. This listener logs that page:&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:firebase_analytics/firebase_analytics.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/foundation.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/services.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:go_router/go_router.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;/// Logs a screen view each time the user sees another screen: another&lt;/span&gt;
&lt;span class="c1"&gt;/// page on top of any navigator of the router, or the same page at&lt;/span&gt;
&lt;span class="c1"&gt;/// another path.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;trackScreenViews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GoRouter&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FirebaseAnalytics&lt;/span&gt; &lt;span class="n"&gt;analytics&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;LocalKey&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="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;routerDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addListener&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;routerDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentConfiguration&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lastOrNull&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;shown&lt;/span&gt; &lt;span class="o"&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;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;ImperativeRouteMatch&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;matches&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;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// a pushed page&lt;/span&gt;
      &lt;span class="n"&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;pages&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;top&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// The error screen. Its location can hold anything, such as a token.&lt;/span&gt;
      &lt;span class="n"&gt;last&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="k"&gt;return&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;screen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pageKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&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;screen&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last&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="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;screen&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fullPath&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logScreenView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;screenName:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;catchError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Object&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="p"&gt;&amp;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;'Screen view not logged: &lt;/span&gt;&lt;span class="si"&gt;$error&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nl"&gt;test:&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="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PlatformException&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;Call it once, right after you create the router, and leave out &lt;code&gt;observers&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;final&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;routes:&lt;/span&gt; &lt;span class="p"&gt;[...]);&lt;/span&gt;
&lt;span class="n"&gt;trackScreenViews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FirebaseAnalytics&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same steps with the listener:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the user does&lt;/th&gt;
&lt;th&gt;What the listener logs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The app starts on Home&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opens the Settings tab, then Home, then Settings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/settings&lt;/code&gt;, &lt;code&gt;/home&lt;/code&gt;, &lt;code&gt;/settings&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;go('/home/details')&lt;/code&gt;, then goes back&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/home/details&lt;/code&gt;, &lt;code&gt;/home&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push('/profile')&lt;/code&gt;, then goes back&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/profile&lt;/code&gt;, &lt;code&gt;/home&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;go('/a/b/c')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/a/b/c&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;go('/item/42')&lt;/code&gt;, then &lt;code&gt;go('/item/43')&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;item&lt;/code&gt;, &lt;code&gt;item&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;go('/home?tab=2')&lt;/code&gt; while on Home&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Screen names, duplicates and the error screen
&lt;/h2&gt;

&lt;p&gt;The listener compares the page on top and its path with the last ones it logged. A pushed page gets a page key of its own, so pushing the same location twice logs it twice: the user saw it twice. &lt;code&gt;/item/42&lt;/code&gt; and &lt;code&gt;/item/43&lt;/code&gt; share a page key in go_router, and the path tells them apart. A change of the query alone, such as a filter, logs nothing.&lt;/p&gt;

&lt;p&gt;A screen is named after its route, or after the full path pattern, such as &lt;code&gt;/item/:id&lt;/code&gt;, when the route has no name. The location itself would make every item a screen of its own in the reports, and a location can hold an email address or a token. Since the listener reads routes rather than pages, a page from a &lt;code&gt;pageBuilder&lt;/code&gt; needs no name.&lt;/p&gt;

&lt;p&gt;When no route matches, the configuration is an error, and the location that failed can hold anything. The listener logs nothing for the error screen and forgets the last screen, so the screen the user goes back to is logged again.&lt;/p&gt;

&lt;p&gt;Nothing waits for &lt;code&gt;logScreenView&lt;/code&gt;, so a &lt;code&gt;PlatformException&lt;/code&gt; would go to the handler of uncaught errors. The listener prints it instead, as &lt;code&gt;FirebaseAnalyticsObserver&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: Firebase Analytics logs screen views of its own
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Added on 25 October 2026.&lt;/em&gt; After this article went out, &lt;a href="https://dev.to/ahmedznouda"&gt;Ahmed ZNOUDA&lt;/a&gt; pointed out two more things that skew screen counts &lt;a href="https://dev.to/ahmedznouda/comment/3io14"&gt;in a comment below&lt;/a&gt;, and asked about redirects. Thanks, Ahmed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turn off automatic screen reporting
&lt;/h3&gt;

&lt;p&gt;Firebase Analytics also logs screen views by itself. They name the activity or the view controller rather than a screen, and a Flutter app has one of each: &lt;code&gt;MainActivity&lt;/code&gt; on Android and &lt;code&gt;FlutterViewController&lt;/code&gt; on iOS. I ran a test app with the debug logging of Firebase Analytics on an Android 12 emulator and an iOS simulator (firebase_analytics 12.6.1). On both, every cold start logged one of these views with no screen name, right before the listener's &lt;code&gt;home.home&lt;/code&gt;. On Android, the app logged another one each time it came back from the background. With the listener in place, turn them off. On Android, inside &lt;code&gt;&amp;lt;application&amp;gt;&lt;/code&gt; of &lt;code&gt;android/app/src/main/AndroidManifest.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&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;"google_analytics_automatic_screen_reporting_enabled"&lt;/span&gt;
    &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"false"&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;On iOS, in the top-level dictionary of &lt;code&gt;ios/Runner/Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&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;key&amp;gt;&lt;/span&gt;FirebaseAutomaticScreenReportingEnabled&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;false/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With both keys, the same runs logged only the listener's screen view at the start, and no screen view when the app came back from the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bottom sheets and dialogs
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;showModalBottomSheet&lt;/code&gt; and &lt;code&gt;showDialog&lt;/code&gt; push a route without a page. The configuration of the router stays the same, so the listener logs nothing when the sheet opens, and nothing for the page below when it closes. &lt;code&gt;FirebaseAnalyticsObserver&lt;/code&gt; leaves them out too, because by default it only logs a &lt;code&gt;PageRoute&lt;/code&gt;. If a sheet is a screen you want in the reports, such as a paywall or a consent sheet, log it yourself when you show it:&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;FirebaseAnalytics&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;logScreenView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;screenName:&lt;/span&gt; &lt;span class="s"&gt;'paywall_sheet'&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;showModalBottomSheet&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="nl"&gt;context:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="n"&gt;buildPaywall&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Redirects
&lt;/h3&gt;

&lt;p&gt;Redirects don't let a screen slip through. go_router runs &lt;code&gt;redirect&lt;/code&gt; while it parses a location, before the delegate gets the new configuration. I checked it on go_router 18.1.1: an app that starts at &lt;code&gt;/home&lt;/code&gt; with an auth redirect to &lt;code&gt;/login&lt;/code&gt; logs only &lt;code&gt;/login&lt;/code&gt;. Signing in then logs &lt;code&gt;/home&lt;/code&gt; once, and signing out logs &lt;code&gt;/login&lt;/code&gt; once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF does Flutter Firebase Analytics screen tracking
&lt;/h2&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt;, a free, open-source Flutter project generator. In every app with go_router and the &lt;a href="https://doc.saymyframe.com/modules/firebase-analytics" rel="noopener noreferrer"&gt;Firebase Analytics module&lt;/a&gt;, the router tells listeners about each screen the user sees, and the module logs a screen view for each one, with no code in the app. Tests that run in the generated apps check that listeners hear about each screen once, tab switches included.&lt;/p&gt;

&lt;p&gt;Firebase has a trap on iOS too, in the build phase that uploads symbols to Crashlytics: &lt;a href="https://saymyframe.com/blog/flutterfire-upload-crashlytics-symbols-error" rel="noopener noreferrer"&gt;how to fix the flutterfire upload-crashlytics-symbols error&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>gorouter</category>
      <category>flutter</category>
      <category>dart</category>
    </item>
    <item>
      <title>SMF 0.6: Flutter push notifications and social sign-in</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-06-flutter-push-notifications-and-social-sign-in-4n40</link>
      <guid>https://dev.to/ybeshkarov/smf-06-flutter-push-notifications-and-social-sign-in-4n40</guid>
      <description>&lt;p&gt;Say My Frame (SMF) generates Flutter apps from independent modules. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.6 is out on pub.dev&lt;/a&gt;. It adds Flutter push notifications with Firebase Cloud Messaging, local notifications, and sign-in with Google and Apple. At the end of &lt;a href="https://saymyframe.com/blog/smf-0-5-flutter-sign-in" rel="noopener noreferrer"&gt;the post about 0.5&lt;/a&gt; these were next on my list. Some of the setup lives in the Apple Developer account and the Firebase console, where no command can reach, so 0.6 also comes with a guide for those steps, click by click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;-m&lt;/span&gt; home,onboarding,sign_in,google_sign_in,apple_sign_in,firebase_messaging,bottom_tabs,material_theme,gen_l10n,get_it,bloc

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-6-flutter-push-notifications%2Fsmf-create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-6-flutter-push-notifications%2Fsmf-create.gif" alt="smf create in a terminal asks for the app name and the modules, among them Google sign-in, Sign in with Apple, the onboarding and the notifications, adds the modules that the chosen ones need, such as go_router, the settings screen and Firebase Authentication, asks who may use the app without an account, and generates a Flutter app with an onboarding, a start screen, sign-in with email, Google and Apple, tabs at the bottom, a theme, two languages, notifications, get_it and BLoC" width="720" height="669"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recording answers the questions of &lt;code&gt;smf create&lt;/code&gt; instead of passing &lt;code&gt;-m&lt;/code&gt;. It picks local notifications and leaves push messages out, so the app needs no APNs key. Either way, SMF adds what the chosen modules need and says why.&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%2Fw94zqt3uaffmigli613c.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%2Fw94zqt3uaffmigli613c.png" alt="The app that smf create generates, on a phone: the onboarding, the sign-in with Google, Apple and an email address, the start screen, and the account screen in the dark theme and in Ukrainian" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As before, what you get is a regular Flutter project. It has no SMF package in its dependencies, and SMF never touches it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flutter push notifications and local notifications after smf create
&lt;/h2&gt;

&lt;p&gt;Two new modules handle notifications, each for a role of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;local_notifications&lt;/code&gt; shows a notification now or at about a time to come, on flutter_local_notifications. It tells the app of each tap, including the tap that launched the app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase_messaging&lt;/code&gt; gives the app the address of the device for its backend, which is the registration token of Firebase Cloud Messaging. It also gives the app the push messages that arrive there and the taps on them. A message that arrives while the app is open shows as a notification too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Push messages lean on the notifications for the permission and for showing a message while the app is open, so &lt;code&gt;firebase_messaging&lt;/code&gt; brings &lt;code&gt;local_notifications&lt;/code&gt; with it. An app can have local notifications without push messages, and then it needs no Firebase project.&lt;/p&gt;

&lt;p&gt;The app asks for nothing when it starts. In an app with an onboarding, one page of it asks for the permission to show notifications. The settings screen gets a row that shows On or Off, asks while the app hasn't asked yet, and opens the settings of the system after that. On Android 12 and older, where apps get the permission without asking, the onboarding skips that page.&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%2Fucy8nglt7erqrrzcvbvm.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%2Fucy8nglt7erqrrzcvbvm.png" alt="Three screens of the generated app: the page of the onboarding " width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://doc.saymyframe.com/guides/notifications" rel="noopener noreferrer"&gt;notifications guide&lt;/a&gt; shows how code schedules a notification, reads the address and listens to messages and taps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign-in with Google and Apple in Flutter
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;google_sign_in&lt;/code&gt; adds sign-in with a Google account on Android and iOS, and &lt;code&gt;apple_sign_in&lt;/code&gt; adds Sign in with Apple on iOS, so on Android its button doesn't show. The sign-in screen of &lt;code&gt;sign_in&lt;/code&gt; shows "Continue with Google" and "Continue with Apple" above the form with the email address, in the theme and in both languages of the app. Firebase Authentication signs the user in with these credentials, links them to an account, and deletes such an account.&lt;/p&gt;

&lt;p&gt;Two rules of the App Store shaped this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An iOS app with Google sign-in also needs a way to sign in that the App Store takes as its equivalent, such as Sign in with Apple (Guideline 4.8). &lt;code&gt;smf create&lt;/code&gt; warns of an app with Google and without Apple.&lt;/li&gt;
&lt;li&gt;An app with Sign in with Apple revokes the tokens of Apple when the user deletes the account (Guideline 5.1.1(v)). The generated app asks Apple again before the deletion, and Firebase revokes the tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After &lt;code&gt;flutterfire configure&lt;/code&gt;, &lt;code&gt;smf create&lt;/code&gt; offers to run a script that enables Google in the Firebase project, registers the SHA-1 and SHA-256 of your debug key and writes the URL scheme of the iOS app. Apple has no such command. The &lt;a href="https://doc.saymyframe.com/guides/sign-in" rel="noopener noreferrer"&gt;sign-in guide&lt;/a&gt; covers both ways to sign in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steps you do by hand, click by click
&lt;/h2&gt;

&lt;p&gt;Some setup can't be done from a terminal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the App ID of a paid Apple team, which push messages and Sign in with Apple need, since a free team can't sign the app with them;&lt;/li&gt;
&lt;li&gt;the APNs key that Firebase needs to send messages to iPhones;&lt;/li&gt;
&lt;li&gt;the Apple provider and its key in Firebase;&lt;/li&gt;
&lt;li&gt;the SHA of your release keys;&lt;/li&gt;
&lt;li&gt;Google Analytics in the Firebase project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked for APIs before writing a guide. Firebase takes an APNs key only in its console, and the API of App Store Connect can't create such a key either.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;smf create&lt;/code&gt; ends with one line that says how many steps your app needs, and one link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finish the setup by hand in 5 steps:
  https://doc.saymyframe.com/setup?steps=app_entry.ios_app_id,firebase_auth.apple_provider,firebase_auth.apple_revocation,firebase_auth.google_release_sha,firebase_messaging.apns_key

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page behind the link shows only those steps, in their order, with screenshots of the Apple Developer account, the Firebase console and Xcode. Each step has a checkbox, so you can come back later and see where you stopped. The README of the app lists the same steps in its section "Finish the setup". In terminals that support it, the link is clickable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Firebase Cloud Messaging and Apple did on real devices
&lt;/h2&gt;

&lt;p&gt;The tests of the modules run the real Firebase packages over a stand-in for their platform side. Before the release I went through push messages, notifications and sign-in by hand on an iPhone simulator, on Android emulators with Android 12 and 17, and against a real Firebase project. A few things there were not what I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the first start, the iPhone simulator had no address for about 13 seconds. The app picks it up when it arrives, so code that sends the address to a backend listens for it instead of reading it once.&lt;/li&gt;
&lt;li&gt;An Android emulator that I restored from a snapshot received no messages at all until a cold boot. Nothing was wrong with the app.&lt;/li&gt;
&lt;li&gt;On iOS, firebase_messaging also answers when a local notification is about to show. Unless the app sets the options of foreground notifications, that answer hides the notification while the app is open. The generated service sets them, so a notification shows once.&lt;/li&gt;
&lt;li&gt;Deleting an account that signed in with Apple, before the key of Apple was in Firebase, failed with "Code flow is not enabled for Apple". The generated app tells the user that this is not available, keeps the account, and shows you the fix in debug mode. With the key in place, the account was deleted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more problem broke every release build on Android, and it had nothing to do with notifications. &lt;code&gt;shared_preferences_android&lt;/code&gt; 2.5.0 came out during this work, and with it a release build crashed before its first frame. I wrote &lt;a href="https://saymyframe.com/blog/shared-preferences-android-release-build-crash" rel="noopener noreferrer"&gt;a separate post about that crash&lt;/a&gt;. Since then, CI also starts a release build on an Android emulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sixteen roles and twenty-two modules, checked in running apps
&lt;/h2&gt;

&lt;p&gt;0.5 had thirteen roles and eighteen modules. 0.6 has sixteen and twenty-two. The new roles are the notifications, the push messages and the onboarding screen, which lets modules add pages such as the one that asks for the permission. The new modules are &lt;code&gt;local_notifications&lt;/code&gt;, &lt;code&gt;firebase_messaging&lt;/code&gt;, &lt;code&gt;google_sign_in&lt;/code&gt; and &lt;code&gt;apple_sign_in&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I wrote this, CI generated 59 apps from the modules of the CLI and ran &lt;code&gt;flutter analyze&lt;/code&gt; and &lt;code&gt;flutter test&lt;/code&gt; in each. It also configures the app with every module for a real Firebase project and starts it on an Android emulator and on an iOS simulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMF 0.6 can't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Firebase is the only provider of push messages and of sign-in.&lt;/li&gt;
&lt;li&gt;There is no networking and no in-app purchases yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;Subscriptions with a paywall are next on my list. There are no dates. SMF is a side project, and I'd rather ship things when they work.&lt;/p&gt;

&lt;p&gt;If you have a module in mind, tell me which one on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.5
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--strict&lt;/code&gt; now stops on a warning about what the app is made of, such as a destination in an app without a main navigation. Each such warning has a code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase_auth&lt;/code&gt; needs the Firebase CLI 15.34.0 or newer. That version no longer adds a web app to a Firebase project that already has apps.&lt;/li&gt;
&lt;li&gt;An app gets the fallback start screen only when no route can start it.&lt;/li&gt;
&lt;li&gt;The module API of &lt;code&gt;smf_contracts&lt;/code&gt; changed: modules can add code to the &lt;code&gt;AppDelegate&lt;/code&gt;, entitlements, ways to sign in and steps by hand. A module written for 0.5 needs an update.&lt;/li&gt;
&lt;li&gt;Apps generated by 0.5 stay as they are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.6.0" rel="noopener noreferrer"&gt;release notes of SMF 0.6.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try Flutter push notifications with smf create
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMF generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer, and it is tested on macOS, Linux and Windows. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, the &lt;a href="https://doc.saymyframe.com/guides/notifications" rel="noopener noreferrer"&gt;notifications guide&lt;/a&gt; goes from the permission to push messages, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Stars there are welcome, but only if you actually like it.&lt;/p&gt;

</description>
      <category>release</category>
      <category>firebase</category>
      <category>ios</category>
      <category>flutter</category>
    </item>
    <item>
      <title>Fix shared_preferences crashing Flutter release builds</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/fix-sharedpreferences-crashing-flutter-release-builds-16n3</link>
      <guid>https://dev.to/ybeshkarov/fix-sharedpreferences-crashing-flutter-release-builds-16n3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 22 October 2026:&lt;/strong&gt; the bug is fixed in &lt;code&gt;shared_preferences_android&lt;/code&gt; 2.5.2. If you added the pin or the keep rules from the workarounds below, remove them. Then run &lt;code&gt;flutter pub upgrade shared_preferences_android&lt;/code&gt; and check that &lt;code&gt;pubspec.lock&lt;/code&gt; has 2.5.2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On 17 October 2026, &lt;code&gt;shared_preferences_android&lt;/code&gt; 2.5.0 came out, and Flutter apps that use &lt;code&gt;shared_preferences&lt;/code&gt; stopped starting in Android release builds. The app stays on the splash screen, and logcat has a &lt;code&gt;NoSuchMethodError&lt;/code&gt;. Debug and profile builds work, and so does iOS, so a CI that builds only debug stays green. This shared_preferences release build crash comes from R8, which renames Kotlin classes that the new version looks up by name. The fix is out in &lt;code&gt;shared_preferences_android&lt;/code&gt; 2.5.2, so upgrade the package. If you can't upgrade yet, go back to the previous version or add six keep rules to the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  NoSuchMethodError in SharedPreferencesAsyncApiRegistrar.getAll
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;SharedPreferencesAsync&lt;/code&gt; or &lt;code&gt;SharedPreferencesWithCache&lt;/code&gt;, the first call to the preferences throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E flutter: Unhandled Exception: PlatformException(PlatformException, java.lang.NoSuchMethodError: no non-static method "Lio/flutter/plugins/sharedpreferences/SharedPreferencesAsyncApiRegistrar;.getAll(Ljava/util/List;Lio/flutter/plugins/sharedpreferences/SharedPreferencesPigeonOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the legacy &lt;code&gt;SharedPreferences.getInstance()&lt;/code&gt;, the class in the message is &lt;code&gt;SharedPreferencesApiRegistrar&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PlatformException(PlatformException, java.lang.NoSuchMethodError: no non-static method "Lio/flutter/plugins/sharedpreferences/SharedPreferencesApiRegistrar;.getAll(Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the app opens the preferences in &lt;code&gt;main()&lt;/code&gt;, before &lt;code&gt;runApp()&lt;/code&gt;, the future fails and &lt;code&gt;runApp()&lt;/code&gt; never runs. The app keeps showing its splash screen, and no crash dialog comes up. An app that opens the preferences later fails on the screen that reads them.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it fails: shared_preferences_android 2.5.0 and R8
&lt;/h2&gt;

&lt;p&gt;Check which version pub resolved, in the directory of the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub deps | &lt;span class="nb"&gt;grep &lt;/span&gt;shared_preferences_android
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have this case when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it prints &lt;code&gt;shared_preferences_android 2.5.0&lt;/code&gt; or &lt;code&gt;2.5.1&lt;/code&gt;. &lt;code&gt;shared_preferences&lt;/code&gt; 2.5.5 allows any 2.x of its Android side from 2.3.4 on, so a &lt;code&gt;flutter pub upgrade&lt;/code&gt;, or a first &lt;code&gt;flutter pub get&lt;/code&gt; between 17 and 22 October, brought one of them in. 2.5.1 only updated a dependency and has the same bug;&lt;/li&gt;
&lt;li&gt;you build for Android in release: &lt;code&gt;flutter build apk&lt;/code&gt;, &lt;code&gt;flutter build appbundle&lt;/code&gt; or &lt;code&gt;flutter run --release&lt;/code&gt;. Flutter turns R8 on for every release build, and the app bundle that you upload to Google Play has the bug too;&lt;/li&gt;
&lt;li&gt;debug and profile builds of the same app work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;iOS and macOS use &lt;code&gt;shared_preferences_foundation&lt;/code&gt;, and the other platforms have packages of their own, so only Android is affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: upgrade to shared_preferences_android 2.5.2
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;shared_preferences_android&lt;/code&gt; 2.5.2 came out on 22 October. It ships consumer rules for R8, which keep the Kotlin classes in every app that uses the plugin. Upgrade it in the directory of the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub upgrade shared_preferences_android
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you added one of the workarounds below, take it out first: remove the dependency on &lt;code&gt;shared_preferences_android&lt;/code&gt; before the upgrade, or delete the six rules from &lt;code&gt;android/app/proguard-rules.pro&lt;/code&gt;, where they do no harm if you leave them. Then check that &lt;code&gt;flutter pub deps | grep shared_preferences_android&lt;/code&gt; prints 2.5.2, and start the release build again with &lt;code&gt;flutter run --release&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 1: keep shared_preferences_android below 2.5.0
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub add &lt;span class="s1"&gt;'shared_preferences_android:&amp;gt;=2.4.28 &amp;lt;2.5.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app then depends on the Android side of the package directly, and pub goes back to 2.4.28, which talks to Kotlin through a method channel as before. The rest of the app stays as it is.&lt;/p&gt;

&lt;p&gt;To move to 2.5.2 later, remove the dependency and upgrade the package. Without the upgrade, &lt;code&gt;pubspec.lock&lt;/code&gt; keeps 2.4.28:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub remove shared_preferences_android
flutter pub upgrade shared_preferences_android
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Workaround 2: keep the Kotlin classes with R8 rules
&lt;/h2&gt;

&lt;p&gt;To stay on 2.5.0 or 2.5.1, tell R8 to keep the classes. Put these lines into &lt;code&gt;android/app/proguard-rules.pro&lt;/code&gt;, and create the file if the app has none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-keep class kotlin.Unit { *; }
-keep class kotlin.Result { *; }
-keep class kotlin.Result$Failure { *; }
-keep class kotlin.coroutines.Continuation { *; }
-keep class kotlin.coroutines.intrinsics.CoroutineSingletons { *; }
-keep class kotlin.coroutines.intrinsics.IntrinsicsKt { *; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Gradle plugin of Flutter adds this file to the release build when it exists, so &lt;code&gt;build.gradle.kts&lt;/code&gt; stays as it is. The rules are those of Step 5 in the &lt;a href="https://github.com/flutter/packages/blob/main/packages/pigeon/native_interop_guide.md#step-5-android-release-build-configuration-jni" rel="noopener noreferrer"&gt;native interop guide of Pigeon&lt;/a&gt;, the tool that generates the code of the plugin.&lt;/p&gt;

&lt;p&gt;After either workaround, start the release build again with &lt;code&gt;flutter run --release&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the shared_preferences release build crash happens
&lt;/h2&gt;

&lt;p&gt;Version 2.5.0 moved the plugin from method channels to the native interop of Pigeon (&lt;a href="https://github.com/flutter/packages/pull/12329" rel="noopener noreferrer"&gt;flutter/packages#12329&lt;/a&gt;). The Dart code now calls the Kotlin code of the plugin through JNI, and JNI finds classes and methods at run time by their names and descriptors.&lt;/p&gt;

&lt;p&gt;R8 renames the classes that, as far as it can see, nothing refers to by name. The generated Kotlin classes of the plugin have &lt;code&gt;@Keep&lt;/code&gt; since &lt;a href="https://github.com/flutter/packages/pull/13151" rel="noopener noreferrer"&gt;flutter/packages#13151&lt;/a&gt;, so &lt;code&gt;SharedPreferencesAsyncApiRegistrar&lt;/code&gt; keeps its name. Its methods are &lt;code&gt;suspend&lt;/code&gt; functions, though, and their last parameter is a &lt;code&gt;kotlin.coroutines.Continuation&lt;/code&gt;. Nothing keeps that class. From &lt;code&gt;build/app/outputs/mapping/release/mapping.txt&lt;/code&gt; of a release build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coroutines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Continuation&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R8 renames the class and rewrites the descriptor of &lt;code&gt;getAll&lt;/code&gt; with the new name. The Dart side still asks for a method that ends with &lt;code&gt;Lkotlin/coroutines/Continuation;&lt;/code&gt;, and that method no longer exists. The same goes for &lt;code&gt;kotlin.Unit&lt;/code&gt;, &lt;code&gt;kotlin.Result&lt;/code&gt; and the coroutine classes that &lt;code&gt;package:jni&lt;/code&gt; 1.1.0 looks up by name in &lt;code&gt;lib/src/kotlin.dart&lt;/code&gt;. The plugin has no consumer rules for R8, and the rules of &lt;code&gt;jni&lt;/code&gt; keep only its own package, &lt;code&gt;com.github.dart_lang.jni.**&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Debug and profile builds run without R8, so every name stays and the calls work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a green CI with debug builds missed it
&lt;/h2&gt;

&lt;p&gt;The release APK builds without an error and fails only when its code runs. I found it by chance on the day 2.5.0 came out: while checking an unrelated change in SMF, I started a release build on an emulator, and it stayed on the splash screen. An app from a plain &lt;code&gt;flutter create&lt;/code&gt; did the same. The CI of SMF builds its generated apps for Android and starts them on an emulator, on every pull request and every night, and every Android build there was a debug build. So it stayed green.&lt;/p&gt;

&lt;p&gt;Your users get a release build, so start one before they do: once a day in CI, or by hand before each release. Build it with &lt;code&gt;flutter build apk --release&lt;/code&gt;, install it on an emulator and check that the first screen shows. R8 runs only in release builds, and that is the only place where a bug like this one shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upstream fix
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/flutter/flutter/issues/194649" rel="noopener noreferrer"&gt;flutter/flutter#194649&lt;/a&gt; is the issue I opened on 17 October, with the steps to reproduce. Within a day, other apps linked to it from their own fixes.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/flutter/packages/pull/13311" rel="noopener noreferrer"&gt;flutter/packages#13311&lt;/a&gt; is my pull request. It adds the six rules to &lt;code&gt;shared_preferences_android&lt;/code&gt; as consumer rules, which R8 applies in every app that uses the plugin. It was merged on 21 October and came out in 2.5.2 a day later, since an outage of the CI of Flutter stopped the first release run.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dart-lang/native/issues/3732" rel="noopener noreferrer"&gt;dart-lang/native#3732&lt;/a&gt; is about shipping the rules in &lt;code&gt;jni&lt;/code&gt; itself, so that every plugin on the native interop of Pigeon gets them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If another plugin of your app moves to the native interop of Pigeon and its release build fails with a &lt;code&gt;NoSuchMethodError&lt;/code&gt; or a &lt;code&gt;ClassNotFoundException&lt;/code&gt; that names a &lt;code&gt;kotlin&lt;/code&gt; class, try the same rules first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF keeps generated apps on shared_preferences_android 2.4.28
&lt;/h2&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt;, a free, open-source Flutter project generator. Since version 0.4.2, released on 17 October, its &lt;a href="https://doc.saymyframe.com/modules/shared-preferences" rel="noopener noreferrer"&gt;shared_preferences module&lt;/a&gt; keeps &lt;code&gt;shared_preferences_android&lt;/code&gt; below 2.5.0 in every app it generates. The guide for coding agents in the app says why, and asks them to start a release build on Android before they raise the constraint. The CI of SMF now also starts a release build of a generated app on an Android emulator.&lt;/p&gt;

&lt;p&gt;On iOS I hit the same kind of trap, where debug works and the release archive fails: the Crashlytics build phase of FlutterFire breaks &lt;code&gt;flutter build ipa&lt;/code&gt; in apps with Swift Package Manager. &lt;a href="https://saymyframe.com/blog/flutterfire-upload-crashlytics-symbols-error" rel="noopener noreferrer"&gt;The fix for the flutterfire upload-crashlytics-symbols error&lt;/a&gt; is in an earlier post.&lt;/p&gt;

</description>
      <category>troubleshooting</category>
      <category>android</category>
      <category>flutter</category>
      <category>dart</category>
    </item>
    <item>
      <title>SMF 0.5: Flutter sign-in with Firebase Authentication</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-05-flutter-sign-in-with-firebase-authentication-4b0o</link>
      <guid>https://dev.to/ybeshkarov/smf-05-flutter-sign-in-with-firebase-authentication-4b0o</guid>
      <description>&lt;p&gt;Say My Frame (SMF) generates Flutter apps from independent modules. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.5 is out on pub.dev&lt;/a&gt;, and it brings Flutter sign-in. An app you generate can now have screens where a user signs in with an email address and a password, creates an account or resets a password. It also gets an account screen, and Firebase Authentication keeps the accounts. The post about 0.4 ended with sign-in as the next thing on my list. This one is about what you get, what it needs from you, and what it still can't do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;-m&lt;/span&gt; home,onboarding,sign_in,bloc,bottom_tabs,gen_l10n,material_theme

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-5-flutter-sign-in%2Fsmf-create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-5-flutter-sign-in%2Fsmf-create.gif" alt="smf create in a terminal asks for the app name and the modules, adds go_router, shared_preferences, firebase_auth, firebase_core and the settings screen, which the chosen modules need, asks who may use the app without an account, and generates a Flutter app with an onboarding, a start screen, sign-in, tabs at the bottom, a theme, two languages, get_it and BLoC" width="720" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recording answers the questions of &lt;code&gt;smf create&lt;/code&gt; instead of passing &lt;code&gt;-m&lt;/code&gt;, and it also picks get_it. Either way, SMF adds what the chosen modules need and says why: &lt;code&gt;sign_in&lt;/code&gt; brings &lt;code&gt;firebase_auth&lt;/code&gt;, &lt;code&gt;firebase_core&lt;/code&gt; and &lt;code&gt;settings&lt;/code&gt; with it.&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%2F2ustj8dw837azsblr512.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%2F2ustj8dw837azsblr512.png" alt="The app that smf create generates, on a phone: the onboarding, the sign-in, the start screen, and the account screen in the dark theme and in Ukrainian" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;home page of Say My Frame&lt;/a&gt; has a video of a walk through this app, with a sign-up.&lt;/p&gt;

&lt;p&gt;An app with &lt;code&gt;sign_in&lt;/code&gt; needs a Firebase project. &lt;code&gt;smf create&lt;/code&gt; offers to run &lt;code&gt;flutterfire configure&lt;/code&gt; once the app is generated. Until that command has run, the app compiles but stops at start-up with an &lt;code&gt;UnsupportedError&lt;/code&gt;, because its Firebase options are still a placeholder.&lt;/p&gt;

&lt;p&gt;As before, what you get is a regular Flutter project. It has no SMF package in its dependencies, and SMF never touches it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Flutter sign-in looks like after smf create
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Sign-in, sign-up, password reset and an account screen
&lt;/h3&gt;

&lt;p&gt;The module &lt;code&gt;sign_in&lt;/code&gt; adds four screens. On three of them a user signs in, creates an account, or asks for the message that resets a password. The password reset says the same whether the address has an account or not. The fourth is the account screen, where the user signs out or deletes the account after a confirmation, and the settings screen gets a row that leads to it.&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%2F8qp63ndcm6jw3jbitgwm.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%2F8qp63ndcm6jw3jbitgwm.png" alt="Four more screens of the generated app: the sign-up with the fields Email and Password and the button Create account, the password reset with the button Send link, the settings screen with the row Account above Theme and Language, and the sheet Delete your account? with a red button Delete and Cancel" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screens take their fields, buttons and colors from the theme of the app, and their texts are in English and Ukrainian. Every reason of a failure has a text of its own, such as a wrong password or no connection. As with the onboarding in 0.4, the tests put the screens on a phone of 320 by 480 with the text at three times its size. The &lt;a href="https://doc.saymyframe.com/modules/sign-in" rel="noopener noreferrer"&gt;page of the sign-in module&lt;/a&gt; shows each screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  One question: who may use the app without an account
&lt;/h3&gt;

&lt;p&gt;In an app with sign-in, &lt;code&gt;smf create&lt;/code&gt; asks one new question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;? Who may use the app without an account? (↑↓ to move, enter to choose)
❯ Nobody: the user signs in first
  Everyone: an account only where a screen needs one
  Everyone, as an anonymous user with an id from the start

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--auth-mode&lt;/code&gt; answers it in advance with &lt;code&gt;required&lt;/code&gt;, &lt;code&gt;guest&lt;/code&gt; or &lt;code&gt;anonymous&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With the first answer, the sign-in shows in place of every screen until the user is signed in.&lt;/li&gt;
&lt;li&gt;With the second, the app is open. When the user asks for a screen that needs an account, such as the account screen, the sign-in opens over the page they are on. Back returns to that page, and after the sign-in the app shows the screen that was asked for.&lt;/li&gt;
&lt;li&gt;The third works the same way, and the app also signs in an anonymous user when it starts, so your code has the id of a user before anyone has an account. Signing up gives that user the account, with the same id.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code of the screens is the same in all three, and the answer is one constant in &lt;code&gt;lib/core/auth/app_session.dart&lt;/code&gt;. The &lt;a href="https://doc.saymyframe.com/guides/sign-in" rel="noopener noreferrer"&gt;sign-in guide&lt;/a&gt; describes the modes and how a screen of your own asks for an account.&lt;/p&gt;

&lt;h3&gt;
  
  
  The screens change the session, and the router does the rest
&lt;/h3&gt;

&lt;p&gt;No screen of &lt;code&gt;sign_in&lt;/code&gt; closes itself or navigates after a sign-in or a sign-out. The module declares two guards of the routes, and the router of the app shows the sign-in and takes it away by what they say. The route of the account screen asks for a condition:&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;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;'/account'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'account'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;screen:&lt;/span&gt; &lt;span class="n"&gt;ScreenRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'AccountScreen'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ImportRef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$_folder&lt;/span&gt;&lt;span class="s"&gt;/account_screen.dart'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;conditions:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AuthRole&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;account&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 authentication role publishes &lt;code&gt;AuthRole.account&lt;/code&gt;, and &lt;code&gt;sign_in&lt;/code&gt; stands for it with a guard. A feature of your own that asks for the same condition gets the same guard without naming &lt;code&gt;sign_in&lt;/code&gt;. The module never mentions go_router either.&lt;/p&gt;

&lt;p&gt;The router role had to learn two things for this. The flow of a guard now closes itself: once the user is signed in, the router takes the sign-in away. And a route can ask for a condition, which opens the sign-in over the screen the user is on and brings the user back. &lt;a href="https://doc.saymyframe.com/concepts/guards" rel="noopener noreferrer"&gt;Guards of routes&lt;/a&gt; and &lt;a href="https://doc.saymyframe.com/concepts/conditions" rel="noopener noreferrer"&gt;conditions of routes&lt;/a&gt; have the rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  BLoC or Riverpod over one look
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sign_in&lt;/code&gt; is the first module of SMF with a variant for each state manager. The views, the forms and the texts are the same files in both. With &lt;code&gt;bloc&lt;/code&gt;, each screen has a cubit, and with &lt;code&gt;riverpod&lt;/code&gt;, a provider that lasts as long as the screen. Either way the state signs in through &lt;code&gt;appSession&lt;/code&gt;, the session of the app, and knows nothing of Firebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firebase Authentication behind a role
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sign_in&lt;/code&gt; keeps no accounts. It requires the authentication role, and the module &lt;code&gt;firebase_auth&lt;/code&gt; provides that role with Firebase Authentication.&lt;/p&gt;

&lt;p&gt;A Firebase project needs one more thing than &lt;code&gt;flutterfire configure&lt;/code&gt;: the ways to sign in are enabled in the project, not in the code. The module generates a script of the app that enables them with the Firebase CLI, 15.6.0 or later, and &lt;code&gt;smf create&lt;/code&gt; offers to run it right after the configuration:&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;dart&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;enable_firebase_sign_in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dart&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks first, because the Firebase CLI adds a web app named "Default Web App" to a project that has none. The Firebase console enables the same methods without it. If you answer no, or the run has no terminal, nothing went wrong, so the run doesn't warn. It lists the steps that are left, in the order to run them, after it says that the app is created. The &lt;a href="https://doc.saymyframe.com/guides/firebase#sign-in-methods-of-firebase-authentication" rel="noopener noreferrer"&gt;Firebase guide&lt;/a&gt; has the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Firebase Authentication answers on a real device
&lt;/h2&gt;

&lt;p&gt;The service that talks to Firebase had tests before it met a device. They run the real firebase_auth package over a stand-in for its platform side, and they passed. Before I merged the module, I ran the same calls by hand on an Android emulator and an iPhone simulator, against a project with sign-in enabled and against one where Authentication had never been opened. The devices answered in ways the stand-in knew nothing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a project without Authentication, Android answers with an unknown error that has &lt;code&gt;CONFIGURATION_NOT_FOUND&lt;/code&gt; in its message. iOS answers with an internal error whose message is the description of the error over many lines.&lt;/li&gt;
&lt;li&gt;Android refuses an empty password with a code that says nothing of the reason. iOS answers that the password is wrong.&lt;/li&gt;
&lt;li&gt;An anonymous user that Android kept on the device comes back with an empty email address instead of no address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated service evens these out, so a screen shows the same text on both platforms. It checks an empty field before it asks Firebase, takes an empty address for none, and gives each code of Firebase one of ten reasons that the screens have a text for. When sign-in is not enabled in the project, the user reads only that sign-in is unavailable, and in debug mode the screen adds a hint for you, with the command and the link that fix it. The &lt;a href="https://doc.saymyframe.com/modules/firebase-auth" rel="noopener noreferrer"&gt;page of the firebase_auth module&lt;/a&gt; lists every code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirteen roles and eighteen modules, checked in running apps
&lt;/h2&gt;

&lt;p&gt;0.4 had twelve roles and sixteen modules. 0.5 has thirteen and eighteen: the new role is authentication, and the new modules are &lt;code&gt;sign_in&lt;/code&gt; and &lt;code&gt;firebase_auth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I wrote this, CI generated 46 apps from the modules of the CLI and ran &lt;code&gt;flutter analyze&lt;/code&gt; and &lt;code&gt;flutter test&lt;/code&gt; in each. The tests of sign-in run in each of the three modes. CI also configures the app with every module for a real Firebase project, enables its sign-in methods there with the script, and starts the app on an Android emulator and on an iOS simulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMF 0.5 can't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sign-in is with an email address and a password. There is no sign-in with Google or Apple yet.&lt;/li&gt;
&lt;li&gt;Firebase Authentication is the only provider of the role, so an app with &lt;code&gt;sign_in&lt;/code&gt; needs a Firebase project and does not start until &lt;code&gt;flutterfire configure&lt;/code&gt; has run.&lt;/li&gt;
&lt;li&gt;The app doesn't ask a new user to confirm the email address, and it has no screen that changes the address or the password.&lt;/li&gt;
&lt;li&gt;Firebase may refuse to delete an account whose user signed in long ago. The screen then tells the user to sign out and sign in again.&lt;/li&gt;
&lt;li&gt;A screen that needs an account can't be a tab of the main navigation or the screen the app starts on.&lt;/li&gt;
&lt;li&gt;There is still no networking and no push notifications, and SMF still can't add a module to an app you already have.&lt;/li&gt;
&lt;li&gt;It generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;Sign-in with Google and Apple and push notifications are next on my list. Adding modules to an app that already exists is still on it too. There are no dates. SMF is a side project, and I'd rather ship things when they work.&lt;/p&gt;

&lt;p&gt;If you have a module in mind, tell me which one on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.4
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No option of &lt;code&gt;smf create&lt;/code&gt; changed or went away. &lt;code&gt;--auth-mode&lt;/code&gt; is new.&lt;/li&gt;
&lt;li&gt;A step that you leave for later, such as &lt;code&gt;flutterfire configure&lt;/code&gt;, is no longer a warning. The run lists it after it says that the app is created.&lt;/li&gt;
&lt;li&gt;In an app without a main navigation, &lt;code&gt;smf create&lt;/code&gt; now warns of each destination that no screen leads to.&lt;/li&gt;
&lt;li&gt;The module API of &lt;code&gt;smf_contracts&lt;/code&gt; changed: a guard now names its stage, and a module that provides the router role handles new answers of the guards. A module written for 0.4 needs an update.&lt;/li&gt;
&lt;li&gt;Apps generated by 0.4 stay as they are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.5.0" rel="noopener noreferrer"&gt;release notes of SMF 0.5.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try Flutter sign-in with smf create
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMF is tested on macOS, Linux and Windows. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, the &lt;a href="https://doc.saymyframe.com/guides/sign-in" rel="noopener noreferrer"&gt;sign-in guide&lt;/a&gt; goes from the screens to the Firebase project, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Stars there are welcome, but only if you actually like it.&lt;/p&gt;

</description>
      <category>release</category>
      <category>firebase</category>
      <category>flutter</category>
      <category>dart</category>
    </item>
    <item>
      <title>Fix the flutterfire upload-crashlytics-symbols error</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Wed, 07 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/fix-the-flutterfire-upload-crashlytics-symbols-error-3fhc</link>
      <guid>https://dev.to/ybeshkarov/fix-the-flutterfire-upload-crashlytics-symbols-error-3fhc</guid>
      <description>&lt;p&gt;&lt;code&gt;flutter run&lt;/code&gt; works, &lt;code&gt;flutter build ios&lt;/code&gt; works, and then &lt;code&gt;flutter build ipa&lt;/code&gt; fails in the build phase that the FlutterFire CLI adds for Firebase Crashlytics. This flutterfire upload-crashlytics-symbols error says that the upload script of Crashlytics is missing. The script is on the disk, but when Xcode archives an app that uses Swift Package Manager, the phase looks for it in the wrong directory. The fix changes one path in the Xcode project, and you have to apply it again after every &lt;code&gt;flutterfire configure&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Could not find the Crashlytics upload symbols script" in flutter build ipa
&lt;/h2&gt;

&lt;p&gt;The phase named &lt;code&gt;FlutterFire: "flutterfire upload-crashlytics-symbols"&lt;/code&gt; uploads the dSYM files of the iOS app to Firebase Crashlytics. With Swift Package Manager, the archive stops in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unhandled exception:
Exception: Could not find the Crashlytics upload symbols script at "/Users/&amp;lt;you&amp;gt;/Library/Developer/Xcode/DerivedData/Runner-&amp;lt;hash&amp;gt;/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run". This usually means CocoaPods or Swift Package Manager has not finished installing "FirebaseCrashlytics" yet, or it was installed to an unexpected location. Try cleaning and rebuilding the project.
#0 UploadCrashlyticsSymbols.run (package:flutterfire_cli/src/commands/upload_symbols.dart:368:7)
Command PhaseScriptExecution failed with a nonzero exit code

**ARCHIVE FAILED**

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got it with flutterfire_cli 1.4.1, the latest version when I wrote this, Flutter 3.44.2 and Xcode 27. The message suggests cleaning and rebuilding. Flutter 3.44 keeps the Swift packages somewhere else, so the script will not turn up in DerivedData however often you clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it fails: flutter build ipa, but not flutter run
&lt;/h2&gt;

&lt;p&gt;You have this case when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Firebase packages of the app come through Swift Package Manager, which Flutter 3.44 uses by default, and not through CocoaPods;&lt;/li&gt;
&lt;li&gt;flutterfire_cli 1.4.1 configured the app on a Mac (it adds the phase only on macOS);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flutter run&lt;/code&gt; and &lt;code&gt;flutter build ios&lt;/code&gt; succeed, and the phase uploads the symbols;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flutter build ipa&lt;/code&gt; fails. A comment in the upstream issue reports the same for Product &amp;gt; Archive in Xcode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;flutter run&lt;/code&gt; fails too, with &lt;code&gt;ProcessException: No such file or directory&lt;/code&gt;, an older flutterfire_cli wrote the phase. That case has its own section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix for the flutterfire upload-crashlytics-symbols error
&lt;/h2&gt;

&lt;p&gt;The phase has the path &lt;code&gt;$BUILD_DIR/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run&lt;/code&gt; twice. Replace both with &lt;code&gt;$SRCROOT/../build/ios/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run&lt;/code&gt;. In the directory of the app:&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="n"&gt;ruby&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="s1"&gt;'f = ARGV[0]; s = File.exist?(f) ? File.binread(f) : ""; t = s.gsub("$BUILD_DIR/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run", "$SRCROOT/../build/ios/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"); if t == s then puts "Nothing to fix in #{f}" else File.binwrite(f, t); puts "Fixed the Crashlytics phase in #{f}" end'&lt;/span&gt; &lt;span class="n"&gt;ios&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="no"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xcodeproj&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pbxproj&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints &lt;code&gt;Fixed the Crashlytics phase in ios/Runner.xcodeproj/project.pbxproj&lt;/code&gt;, or &lt;code&gt;Nothing to fix in ios/Runner.xcodeproj/project.pbxproj&lt;/code&gt; when the file has no such path, for example because you fixed it already. Nothing else in the project changes. You can make the same edit by hand in Xcode: the Runner target, Build Phases, the phase with the name above.&lt;/p&gt;

&lt;p&gt;After that, &lt;code&gt;flutter build ipa&lt;/code&gt; archives the app and the phase uploads the symbols. &lt;code&gt;flutter run&lt;/code&gt; and &lt;code&gt;flutter build ios&lt;/code&gt; work as before, because for them both paths are the same directory.&lt;/p&gt;

&lt;p&gt;Two caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;flutterfire configure&lt;/code&gt; writes the phase again when its script differs from the one it would write. Run the fix after every &lt;code&gt;flutterfire configure&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The new path assumes that Flutter builds into &lt;code&gt;build&lt;/code&gt;. If you changed that with &lt;code&gt;flutter config --build-dir&lt;/code&gt;, the path is wrong for you. Flutter exports &lt;code&gt;FLUTTER_APPLICATION_PATH&lt;/code&gt; and &lt;code&gt;FLUTTER_BUILD_DIR&lt;/code&gt; to the phase, so &lt;code&gt;"${FLUTTER_APPLICATION_PATH}/${FLUTTER_BUILD_DIR}/ios/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"&lt;/code&gt; should follow your setting. I have tried only the default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the phase misses the Crashlytics upload script
&lt;/h2&gt;

&lt;p&gt;The script of the phase looks for the upload script in four places, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;$PODS_ROOT/FirebaseCrashlytics/run&lt;/code&gt;, for CocoaPods;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$BUILD_DIR/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the same &lt;code&gt;SourcePackages&lt;/code&gt; path in the DerivedData directory of the project;&lt;/li&gt;
&lt;li&gt;whatever &lt;code&gt;find&lt;/code&gt; turns up in &lt;code&gt;$BUILD_DIR&lt;/code&gt; and in that DerivedData directory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With Swift Package Manager, Flutter clones the packages of the app into &lt;code&gt;build/ios/SourcePackages&lt;/code&gt;: it passes &lt;code&gt;-clonedSourcePackagesDirPath&lt;/code&gt; to &lt;code&gt;xcodebuild&lt;/code&gt;. For &lt;code&gt;flutter run&lt;/code&gt; and &lt;code&gt;flutter build ios&lt;/code&gt; it also passes &lt;code&gt;BUILD_DIR=&amp;lt;app&amp;gt;/build/ios&lt;/code&gt;, so the second place has the script. For an archive it leaves &lt;code&gt;BUILD_DIR&lt;/code&gt; alone, on purpose:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildAction&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt;
          &lt;span class="nx"&gt;XcodeBuildAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// dSYM files aren't copied to the archive if BUILD_DIR is set.&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUILD_DIR=${globals.fs.path.absolute(buildDirectoryPath)}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So during &lt;code&gt;flutter build ipa&lt;/code&gt;, &lt;code&gt;$BUILD_DIR&lt;/code&gt; is the one Xcode picks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/Library/Developer/Xcode/DerivedData/Runner-&amp;lt;hash&amp;gt;/Build/Intermediates.noindex/ArchiveIntermediates/Runner/BuildProductsPath

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the four places has the script. The phase hands flutterfire the DerivedData path of the third place, and that is the path in the error. The stable branch of Flutter had the same lines on the day I wrote this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$SRCROOT&lt;/code&gt; is the &lt;code&gt;ios&lt;/code&gt; directory of the app. &lt;code&gt;$SRCROOT/../build/ios/SourcePackages&lt;/code&gt; is where Flutter put the packages, whatever &lt;code&gt;$BUILD_DIR&lt;/code&gt; says.&lt;/p&gt;

&lt;h2&gt;
  
  
  ProcessException: No such file or directory with flutterfire_cli 1.4.0 or older
&lt;/h2&gt;

&lt;p&gt;The phase that flutterfire_cli 1.4.0 and older versions write knows only CocoaPods and DerivedData. With Flutter 3.44 and Swift Package Manager it fails in every iOS build, &lt;code&gt;flutter run&lt;/code&gt; included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unhandled exception:
ProcessException: No such file or directory
  Command:
  /Users/&amp;lt;you&amp;gt;/Library/Developer/Xcode/DerivedData/Runner-&amp;lt;hash&amp;gt;/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run --validate --flutter-project .../app_id_file.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Activating flutterfire_cli 1.4.1 does not fix it, because the old phase stays in the Xcode project. Activate it and configure the app again on a Mac, and the new phase replaces the old one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate flutterfire_cli 1.4.1
flutterfire configure

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply the fix above, for &lt;code&gt;flutter build ipa&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the fix stands in flutterfire_cli
&lt;/h2&gt;

&lt;p&gt;The upstream issue is &lt;a href="https://github.com/invertase/flutterfire_cli/issues/443" rel="noopener noreferrer"&gt;invertase/flutterfire_cli#443&lt;/a&gt;, open since May 2026. Version 1.4.1, from August, added the lookup in &lt;code&gt;$BUILD_DIR/SourcePackages&lt;/code&gt;, which covers &lt;code&gt;flutter run&lt;/code&gt; and &lt;code&gt;flutter build ios&lt;/code&gt;. The comments of the issue describe the archive case, the latest of them from the end of September. Two open pull requests change the lookup: &lt;a href="https://github.com/invertase/flutterfire_cli/pull/444" rel="noopener noreferrer"&gt;#444&lt;/a&gt; and &lt;a href="https://github.com/invertase/flutterfire_cli/pull/456" rel="noopener noreferrer"&gt;#456&lt;/a&gt;. &lt;a href="https://github.com/invertase/flutterfire_cli/issues/428" rel="noopener noreferrer"&gt;#428&lt;/a&gt; is about the same phase in macOS apps, which I have not tried.&lt;/p&gt;

&lt;p&gt;When a release with one of them is out, activate it and run &lt;code&gt;flutterfire configure&lt;/code&gt; again. You will not need the fix from this post then.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SMF applies the fix after flutterfire configure
&lt;/h2&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;SMF&lt;/a&gt;, a free, open-source Flutter project generator. &lt;a href="https://saymyframe.com/blog/smf-0-3-flutter-project-generator" rel="noopener noreferrer"&gt;The launch post of SMF 0.3&lt;/a&gt; says what it does today and what it can't do yet. For an app with Firebase Crashlytics, SMF runs this fix on macOS right after &lt;code&gt;flutterfire configure&lt;/code&gt;, and puts the command into the README of the app for the next time you configure it yourself. Its CI archives a generated app with &lt;code&gt;flutter build ipa --no-codesign&lt;/code&gt; and the fixed phase. Another job runs the real &lt;code&gt;flutterfire configure&lt;/code&gt; and fails when the phase no longer has the path that the fix replaces, so a change upstream shows up as a red job. The &lt;a href="https://doc.saymyframe.com/guides/firebase#crashlytics-and-flutter-build-ipa" rel="noopener noreferrer"&gt;Firebase guide of SMF&lt;/a&gt; has the details.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>ios</category>
      <category>opensource</category>
    </item>
    <item>
      <title>SMF 0.4: a Flutter starter app with onboarding and settings</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Wed, 07 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-04-a-flutter-starter-app-with-onboarding-and-settings-3kah</link>
      <guid>https://dev.to/ybeshkarov/smf-04-a-flutter-starter-app-with-onboarding-and-settings-3kah</guid>
      <description>&lt;p&gt;Say My Frame (SMF) generates Flutter apps from independent modules. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.4 is out on pub.dev&lt;/a&gt;, and what it generates now is a Flutter starter app that you can show to someone. It opens with an onboarding, greets you on a start screen that lists what to do next, and has a settings screen where the user chooses the theme and the language. The post about 0.3 said that the app you get is a skeleton. This one is about what replaced the skeleton, what is still missing, and what I plan next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;-m&lt;/span&gt; home,onboarding,settings,bottom_tabs,material_theme,gen_l10n,get_it,bloc

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-4-flutter-starter-app%2Fsmf-create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fsaymyframe.com%2Fblog%2Fsmf-0-4-flutter-starter-app%2Fsmf-create.gif" alt="smf create in a terminal asks for the app name and the modules, adds go_router and shared_preferences, which the chosen modules need, and generates a Flutter app with an onboarding, a start screen, a settings screen, tabs at the bottom, a theme, two languages, get_it and BLoC" width="759" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recording answers the questions of &lt;code&gt;smf create&lt;/code&gt; instead of passing &lt;code&gt;-m&lt;/code&gt;. Either way, SMF adds &lt;code&gt;go_router&lt;/code&gt; and &lt;code&gt;shared_preferences&lt;/code&gt; by itself: the start screen needs a router, and the onboarding needs a place to remember that the user has finished it.&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%2Fy6ay29kxe9uvdfc76t8v.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%2Fy6ay29kxe9uvdfc76t8v.png" alt="The app that smf create generates, on a phone: the onboarding, the start screen, the settings screen, and the start screen in the dark theme and in Ukrainian" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;home page of Say My Frame&lt;/a&gt; has a half-minute video of a walk through this app.&lt;/p&gt;

&lt;p&gt;0.3 put the work into the module model, and the app it generated showed none of it: after &lt;code&gt;smf create&lt;/code&gt; you saw one screen with the name of the app. 0.4 went into the app.&lt;/p&gt;

&lt;p&gt;As before, what you get is a regular Flutter project. It has no SMF package in its dependencies, and SMF never touches it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Flutter starter app has after smf create
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Flutter onboarding screen on the first launch
&lt;/h3&gt;

&lt;p&gt;The module &lt;code&gt;onboarding&lt;/code&gt; adds two pages that a new user goes through once. Its buttons only finish the onboarding and navigate nowhere. The module declares a guard of the routes, and the router of the app shows the onboarding in place of every other screen until the user has finished it:&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="err"&gt;guards:&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="err"&gt;RouteGuard(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;name:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'firstRun'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;allows:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;FunctionRef('onboardingCompleted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;import:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_status)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;redirectTo:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&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="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;_status&lt;/code&gt; is the file with that function, and &lt;code&gt;_route&lt;/code&gt; is the route of the onboarding itself. The module never mentions go_router. Guards are part of the router role, so another router would have to ask them the same way. The app remembers that the onboarding is finished, and &lt;code&gt;onboardingStatus.restart()&lt;/code&gt; shows it again.&lt;/p&gt;

&lt;p&gt;The pages are a list in &lt;code&gt;lib/features/onboarding/onboarding_pages.dart&lt;/code&gt;, and you replace them with your own:&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="n"&gt;Widget&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onboardingPages&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="p"&gt;[&lt;/span&gt;
  &lt;span class="n"&gt;OnboardingPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;symbol:&lt;/span&gt; &lt;span class="s"&gt;'Ma'&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;'My App'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;text:&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;l10n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onboardingWelcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;OnboardingPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;icon:&lt;/span&gt; &lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;check_rounded&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;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;l10n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onboardingReadyTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;text:&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;l10n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onboardingReady&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 &lt;a href="https://doc.saymyframe.com/modules/onboarding" rel="noopener noreferrer"&gt;page of the onboarding module&lt;/a&gt; has the rest, and &lt;a href="https://doc.saymyframe.com/concepts/guards" rel="noopener noreferrer"&gt;guards of routes&lt;/a&gt; explains how a module keeps the user away from a screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  A start screen written for the developer
&lt;/h3&gt;

&lt;p&gt;In 0.3 the start screen of &lt;code&gt;home&lt;/code&gt; showed the name of the app. Now it is a welcome for you, the developer. It names the app, greets you by the time of the day and lists three next steps. Each step has the path of a file, and a tap copies it. The first step is to replace this screen with the first screen of your app, and the &lt;a href="https://doc.saymyframe.com/modules/home" rel="noopener noreferrer"&gt;page of the home module&lt;/a&gt; shows how.&lt;/p&gt;

&lt;h3&gt;
  
  
  A settings screen that the modules fill
&lt;/h3&gt;

&lt;p&gt;The module &lt;code&gt;settings&lt;/code&gt; adds a settings screen and knows nothing about what is on it. Modules and roles give it entries. In an app with a theme and a localization, the screen has the theme mode (light, dark or the one of the device) and the language.&lt;/p&gt;

&lt;p&gt;Neither entry comes from the module that provides the theme or the texts. The theme mode belongs to the theme role and the language to the localization role, so a second theme module would get the same entry without writing it. In an app whose modules have no setting, the screen shows a note for the developer instead of an empty list.&lt;/p&gt;

&lt;h3&gt;
  
  
  A light and a dark Material 3 theme with a bundled font
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;material_theme&lt;/code&gt; generates &lt;code&gt;lib/core/theme/app_theme.dart&lt;/code&gt; with a palette, text styles and the look of the components. That file is yours to edit, and the &lt;a href="https://doc.saymyframe.com/guides/theme" rel="noopener noreferrer"&gt;theme guide&lt;/a&gt; says what to change and where. The font files are in &lt;code&gt;assets/fonts/geist/&lt;/code&gt; with their license, so the app loads no font from the network and gets no package for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flutter localization in two languages with gen-l10n
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gen_l10n&lt;/code&gt; writes the texts of the modules into ARB files, one for each language, and &lt;code&gt;flutter pub get&lt;/code&gt; generates the class that code reads them through. The modules come with texts in English and Ukrainian. &lt;code&gt;--locales&lt;/code&gt; chooses the languages of the app. Until the user chooses one, the app follows the device.&lt;/p&gt;

&lt;p&gt;The theme mode and the language are remembered between launches. &lt;code&gt;shared_preferences&lt;/code&gt; keeps them, and it is meant only for settings that are no secret. Code changes them with one call each:&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;appThemeMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;choose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ThemeMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dark&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;appLocale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;choose&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;Locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'uk'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://doc.saymyframe.com/guides/languages" rel="noopener noreferrer"&gt;languages guide&lt;/a&gt; and the &lt;a href="https://doc.saymyframe.com/guides/preferences" rel="noopener noreferrer"&gt;preferences guide&lt;/a&gt; have the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Large text, less motion and a screen reader
&lt;/h3&gt;

&lt;p&gt;I did not want screens that look right only on my phone. The tests of the onboarding and of the start screen put them on a phone of 320 by 480 with the text at three times its size, in each language of the app, and fail when something overflows. The motion of these screens ends, and an app that asks for less motion shows them at once. Titles are headers for a screen reader, and each tab of the bar at the bottom is one button for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A guide for coding agents in every app
&lt;/h3&gt;

&lt;p&gt;Every generated app has &lt;code&gt;AGENTS.md&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt;. The guide says where the files of each role are, how code of the app uses them and what code must not do. The roles and the modules of the app write its sections, so it describes this app and not SMF in general. There is an example in the &lt;a href="https://doc.saymyframe.com/guides/coding-agents" rel="noopener noreferrer"&gt;coding agents guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twelve roles and sixteen modules that still don't know each other
&lt;/h2&gt;

&lt;p&gt;0.3 had eight roles and eleven modules. 0.4 has twelve roles and sixteen modules. The new roles are the preferences, the settings screen, the localization and the theme, and the new modules are &lt;code&gt;onboarding&lt;/code&gt;, &lt;code&gt;settings&lt;/code&gt;, &lt;code&gt;material_theme&lt;/code&gt;, &lt;code&gt;gen_l10n&lt;/code&gt; and &lt;code&gt;shared_preferences&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule of 0.3 held: a module names roles and never another module. A feature shows its texts through the localization role, and in an app without it the same feature shows them in English. The screens read their colors from the theme of the app, whichever module provides it, and they work in an app with no theme module too.&lt;/p&gt;

&lt;p&gt;CI checks this by running the apps. When I wrote this, it generated 32 apps from the modules of the CLI and ran &lt;code&gt;flutter analyze&lt;/code&gt; and &lt;code&gt;flutter test&lt;/code&gt; in each. It also starts apps on an Android emulator and on an iOS simulator. Tests of the roles run inside the generated apps. To show that these tests can fail, the repository keeps a provider with one planted bug for each role they cover, and CI checks that the tests fail on it. &lt;a href="https://doc.saymyframe.com/concepts/independence" rel="noopener noreferrer"&gt;Module independence&lt;/a&gt; in the docs describes the rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMF 0.4 can't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;There is no sign-in, no networking and no push notifications.&lt;/li&gt;
&lt;li&gt;SMF creates new apps. It still can't add a module to an app you already have.&lt;/li&gt;
&lt;li&gt;The texts of the modules are in English and Ukrainian. For another language of &lt;code&gt;--locales&lt;/code&gt;, the app shows them in English until you translate its ARB file.&lt;/li&gt;
&lt;li&gt;Most roles have one provider: one router, one theme, one way to keep the texts.&lt;/li&gt;
&lt;li&gt;It generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;Sign-in is next on my list: email and password, and screens only for users who are signed in. The guards of routes that the onboarding uses were built with that in mind. Adding modules to an app that already exists is still on the list too. There are no dates. SMF is a side project, and I'd rather ship things when they work.&lt;/p&gt;

&lt;p&gt;If you have a module in mind, tell me which one on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.3
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No option of &lt;code&gt;smf create&lt;/code&gt; changed or went away. &lt;code&gt;--locales&lt;/code&gt; is new.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;home&lt;/code&gt; is a welcome with next steps now, and &lt;code&gt;bottom_tabs&lt;/code&gt; draws its bar in the colors of the theme.&lt;/li&gt;
&lt;li&gt;The module API of &lt;code&gt;smf_contracts&lt;/code&gt; changed in several places, so a module written for 0.3 needs an update.&lt;/li&gt;
&lt;li&gt;Apps generated by 0.3 stay as they are. SMF generates an app once and never touches it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.4.0" rel="noopener noreferrer"&gt;release notes of SMF 0.4.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Flutter starter app
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMF is tested on macOS, Linux and Windows. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, the &lt;a href="https://doc.saymyframe.com/getting-started/generated-app" rel="noopener noreferrer"&gt;tour of a generated app&lt;/a&gt; goes through its files, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Stars there are welcome, but only if you actually like it.&lt;/p&gt;

</description>
      <category>release</category>
      <category>flutter</category>
      <category>dart</category>
    </item>
    <item>
      <title>SMF 0.3: an open-source Flutter project generator</title>
      <dc:creator>Yevhen Beshkarov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ybeshkarov/smf-03-an-open-source-flutter-project-generator-5dja</link>
      <guid>https://dev.to/ybeshkarov/smf-03-an-open-source-flutter-project-generator-5dja</guid>
      <description>&lt;p&gt;Say My Frame (SMF) is a free, open-source Flutter project generator whose modules are checked together: its CI generates apps from many combinations of them and analyzes each one. It also sets up Firebase Crashlytics and Analytics all the way to &lt;code&gt;flutter build ipa&lt;/code&gt;. You choose what the app needs, such as go_router, tabs at the bottom, get_it, and BLoC or Riverpod, and &lt;code&gt;smf create&lt;/code&gt; generates a Flutter project in which these parts already work together. &lt;a href="https://pub.dev/packages/smf_flutter_cli" rel="noopener noreferrer"&gt;Version 0.3 is out on pub.dev&lt;/a&gt;. It does less than the word "generator" might promise, so this post is about what it does today, what it can't do yet, and where I want to take it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create my_app &lt;span class="nt"&gt;--org&lt;/span&gt; com.example &lt;span class="nt"&gt;-m&lt;/span&gt; home,bottom_tabs,get_it,bloc &lt;span class="nt"&gt;--no-input&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdoc.saymyframe.com%2Fdemo%2Fsmf_create.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdoc.saymyframe.com%2Fdemo%2Fsmf_create.gif" alt="smf create in a terminal asks for the app name, the organization and the modules, adds go_router for the start screen and generates a Flutter app with tabs at the bottom, BLoC and get_it" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What you get is a regular Flutter project. It has no SMF package in its dependencies, and nothing in it has to be generated again, so the code is yours from the first commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SMF 0.3 is a rewrite
&lt;/h2&gt;

&lt;p&gt;SMF 0.1 came out in August 2025, and 0.2 followed in September. Each module brought its own templates, and an engine patched the shared files. It worked for the combinations I had tried.&lt;/p&gt;

&lt;p&gt;This September I ran the published 0.2 with Flutter 3.44. It reported success and generated apps that did not build. Some modules assumed go_router or get_it, and CI back then never generated an app or ran the analyzer, so nothing caught it.&lt;/p&gt;

&lt;p&gt;0.3 starts from one rule: modules do not know each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Flutter project generator does today
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Modules name roles, not other modules
&lt;/h3&gt;

&lt;p&gt;The start screen needs a router, not go_router. The analytics module logs screen views when the app has a router, whichever router that is. SMF calls these shared parts roles, and there are eight of them: app entry, router, layout, state management, dependency injection, events, crash reporting and analytics. A role can have more than one provider. BLoC and Riverpod both provide state management.&lt;/p&gt;

&lt;p&gt;Modules don't edit files either. Shared files, such as &lt;code&gt;bootstrap.dart&lt;/code&gt;, have sockets: named places where modules put code without knowing who else puts code there. This is how &lt;code&gt;firebase_analytics&lt;/code&gt; hands the router a listener of the screen, and only when the app has a router:&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;const&lt;/span&gt; &lt;span class="n"&gt;SocketContribution&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;RouterRole&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;screenListeners&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;Fragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'logFirebaseScreenView'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;imports:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="n"&gt;ImportRef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;'core/analytics/firebase_analytics_service.dart'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;show:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'logFirebaseScreenView'&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;when&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;routerRole&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;Before it writes a single file, &lt;code&gt;smf create&lt;/code&gt; checks the rules. A module may put code only into the sockets of its own roles, of the app entry and of the modules it depends on, and every file of the app has exactly one owner. The tests of every module run the same checks. CI then generates apps from combinations of the modules, 18 of them for the modules of the CLI when I wrote this, runs &lt;code&gt;flutter analyze&lt;/code&gt; on each one and runs tests in some of them. The &lt;a href="https://doc.saymyframe.com/concepts/module-model" rel="noopener noreferrer"&gt;module model&lt;/a&gt; and &lt;a href="https://doc.saymyframe.com/concepts/independence" rel="noopener noreferrer"&gt;module independence&lt;/a&gt; pages of the docs go deeper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firebase Crashlytics and Analytics, set up all the way to &lt;code&gt;flutter build ipa&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Three modules bring Firebase: &lt;code&gt;firebase_core&lt;/code&gt;, &lt;code&gt;firebase_crashlytics&lt;/code&gt; and &lt;code&gt;firebase_analytics&lt;/code&gt;. Before it generates anything, SMF checks the machine for the Firebase CLI, a Firebase login and the FlutterFire CLI. In a terminal it offers to set up what is missing, and asks first. After generation it offers to run &lt;code&gt;flutterfire configure&lt;/code&gt; with the ids of the app.&lt;/p&gt;

&lt;p&gt;Two details took longer than the rest. With a router, &lt;code&gt;firebase_analytics&lt;/code&gt; logs one screen view for each screen the user sees, tab switches included. And on macOS, SMF fixes the Crashlytics build phase that flutterfire_cli 1.4.1 adds, because &lt;code&gt;flutter build ipa&lt;/code&gt; fails in that phase when the app uses Swift Package Manager. That story gets a post of its own. Until then, the &lt;a href="https://doc.saymyframe.com/guides/firebase" rel="noopener noreferrer"&gt;Firebase guide&lt;/a&gt; has the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Flutter app with go_router, tabs, get_it and BLoC or Riverpod
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;-m home,bottom_tabs,get_it,event_bus&lt;/code&gt;, SMF adds two more modules and tells you why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Adding flutter_core: the only provider of the app entry role, which every app needs.
Adding go_router: the only provider of the router role, which home requires.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Screens navigate with &lt;code&gt;context.nav.home.home().go()&lt;/code&gt;, which doesn't mention go_router. Code that needs a service asks the &lt;code&gt;ServiceLocator&lt;/code&gt; of the DI role, which doesn't mention get_it. With &lt;code&gt;bottom_tabs&lt;/code&gt;, the destinations of the features become tabs of a &lt;code&gt;NavigationBar&lt;/code&gt;, and each tab keeps its own stack. &lt;code&gt;bootstrap()&lt;/code&gt; runs the start-up code of the modules in four phases before the first frame. The Android and iOS projects are the ones &lt;code&gt;flutter create&lt;/code&gt; writes in Flutter 3.44.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://doc.saymyframe.com/getting-started/generated-app" rel="noopener noreferrer"&gt;tour of a generated app&lt;/a&gt; goes file by file and names the module that wrote each one.&lt;/p&gt;

&lt;h3&gt;
  
  
  A plan before anything is written
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--explain&lt;/code&gt; prints what would be generated and stops. It lists the modules and why each is there, the roles, the dependencies, the commands that would run after generation and the state of the machine:&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="s"&gt;Modules&lt;/span&gt;
  &lt;span class="s"&gt;home&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requested&lt;/span&gt;
  &lt;span class="s"&gt;bottom_tabs&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requested&lt;/span&gt;
  &lt;span class="s"&gt;get_it&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requested&lt;/span&gt;
  &lt;span class="s"&gt;event_bus&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requested&lt;/span&gt;
  &lt;span class="s"&gt;flutter_core&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;the only provider of the app entry role, which every app needs&lt;/span&gt;
  &lt;span class="s"&gt;go_router&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;the only provider of the router role, which home requires&lt;/span&gt;

&lt;span class="s"&gt;Roles&lt;/span&gt;
  &lt;span class="s"&gt;Layout&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bottom_tabs&lt;/span&gt;
  &lt;span class="s"&gt;Dependency injection&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get_it&lt;/span&gt;
  &lt;span class="s"&gt;Events&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_bus&lt;/span&gt;
  &lt;span class="s"&gt;App entry&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter_core&lt;/span&gt;
  &lt;span class="s"&gt;Router&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;go_router&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For scripts and CI there are &lt;code&gt;--no-input&lt;/code&gt;, &lt;code&gt;--skip-external-setup&lt;/code&gt;, &lt;code&gt;--strict&lt;/code&gt; and exit codes: 0, 1, 64, 70 and 130. SMF renders the app in memory and finishes it in a temporary directory, so a run that fails or that you cancel leaves no half-made app in your project. The &lt;a href="https://doc.saymyframe.com/guides/smf-create" rel="noopener noreferrer"&gt;reference of smf create&lt;/a&gt; lists every option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modules of your own
&lt;/h3&gt;

&lt;p&gt;Modules are Dart packages built on &lt;code&gt;smf_contracts&lt;/code&gt;, and yours follow the same rules as the built-in ones. The contract harness in &lt;code&gt;smf_pipeline&lt;/code&gt; generates in memory every app that matters for your module and checks it. To generate apps with your module, you write a small command of your own with &lt;code&gt;runCli&lt;/code&gt; and &lt;code&gt;smfModules&lt;/code&gt;. &lt;a href="https://doc.saymyframe.com/extending" rel="noopener noreferrer"&gt;Extending SMF&lt;/a&gt; shows how, and one of its pages builds &lt;a href="https://doc.saymyframe.com/extending/feature-module" rel="noopener noreferrer"&gt;a counter feature with BLoC and Riverpod variants&lt;/a&gt; from start to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMF 0.3 can't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The app you get is a skeleton. There is one feature module, &lt;code&gt;home&lt;/code&gt;, a start screen with the name of the app. The tab bar stays hidden until a second screen is a destination, so after &lt;code&gt;smf create&lt;/code&gt; you see one screen.&lt;/li&gt;
&lt;li&gt;There are no modules for sign-in, networking, localization or theming. The list has eleven modules, and I wanted the foundation to hold before adding more.&lt;/li&gt;
&lt;li&gt;SMF creates new apps. It can't add a module to an app you already have.&lt;/li&gt;
&lt;li&gt;It generates Android and iOS apps for Flutter 3.44 or newer and Dart 3.12 or newer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flutterfire configure&lt;/code&gt; needs you at the keyboard to log in and pick a Firebase project. In CI, SMF leaves it for later and prints the command.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;Two things are next on my list: modules with real screens, and a way to add modules to an app that already exists. There are no dates. SMF is a side project, and I'd rather ship things when they work.&lt;/p&gt;

&lt;p&gt;If you have a module in mind, tell me which one on &lt;a href="https://saymyframe.com/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or in &lt;a href="https://github.com/saymyframe/smf_flutter_cli/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 0.2
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The state manager is a module now: &lt;code&gt;-m bloc&lt;/code&gt; or &lt;code&gt;-m riverpod&lt;/code&gt; instead of &lt;code&gt;-s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--start&lt;/code&gt; replaces &lt;code&gt;-r&lt;/code&gt; and &lt;code&gt;--route&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--strict&lt;/code&gt; and &lt;code&gt;--on-conflict&lt;/code&gt; are options of &lt;code&gt;create&lt;/code&gt; now, so they go after it: &lt;code&gt;smf create my_app --strict&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tabs at the bottom are a module of their own, &lt;code&gt;bottom_tabs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The module API of &lt;code&gt;smf_contracts&lt;/code&gt; is new, so modules written for 0.2 need a rewrite.&lt;/li&gt;
&lt;li&gt;Apps generated by 0.2 stay as they are. SMF generates an app once and never touches it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same list, with what's new in 0.3, is in the &lt;a href="https://github.com/saymyframe/smf_flutter_cli/releases/tag/smf_flutter_cli-v0.3.0" rel="noopener noreferrer"&gt;release notes of SMF 0.3.0 on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Flutter project generator
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart pub global activate smf_flutter_cli
smf create

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMF is tested on macOS, Linux and Windows. The &lt;a href="https://doc.saymyframe.com/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; starts with installation and your first app, the &lt;a href="https://saymyframe.com/" rel="noopener noreferrer"&gt;Say My Frame site&lt;/a&gt; shows the roles and modules at a glance, and the code is &lt;a href="https://github.com/saymyframe/smf_flutter_cli" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Stars there are welcome, but only if you actually like it.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>opensource</category>
      <category>firebase</category>
    </item>
  </channel>
</rss>
