<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Flutter Community - Medium]]></title>
        <description><![CDATA[Articles and Stories from the Flutter Community - Medium]]></description>
        <link>https://medium.com/flutter-community?source=rss----86fb29d7cc6a---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Flutter Community - Medium</title>
            <link>https://medium.com/flutter-community?source=rss----86fb29d7cc6a---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 14:14:14 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/flutter-community" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Flame Gallery: Drop-in Mini Games for Flutter Apps]]></title>
            <link>https://medium.com/flutter-community/flame-gallery-drop-in-mini-games-for-flutter-apps-6df3476482fb?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/6df3476482fb</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[flame-engine]]></category>
            <category><![CDATA[game-development]]></category>
            <dc:creator><![CDATA[Veli Bacık]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 17:02:27 GMT</pubDate>
            <atom:updated>2026-10-28T17:02:26.421Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yxZ5WTGjfHt2dmLt3oRxvg.png" /></figure><h3>One line, one game</h3><p>Marketing asks for “a little game in the app” every few months: a summer campaign, a loyalty reward, a daily challenge. Each time, a team builds a one-off screen that nobody wants to maintain afterwards.</p><p>Flame Gallery turns that into one dependency and one call:</p><pre>final result = await FlameGallery.show(context, game: const FruitSlice());</pre><p>The call pushes a full screen game with start, pause and game over menus, and completes with a GameResult you can turn into points, coupons or analytics. It is open source (MIT), built on <a href="https://flame-engine.org">Flame</a>, and you can <a href="https://vb10.github.io/flutter_flame_gallery/play/">play it in your browser</a> right now.</p><p><strong>In numbers:</strong> 6 packages on pub.dev · 4 games · about 6,000 lines of library code · unit and game tests in every package · 6 Maestro end-to-end flows on iOS and Android.</p><h3>From WebView games to native power</h3><p>For years, the mini game inside a mobile app was usually an HTML5 game: JavaScript running in a WebView. It works, but you can feel it. Here is what changes when the game is drawn by Flutter itself:</p><ul><li><strong>Rendering.</strong> A WebView game runs a browser engine inside your app. Flame Gallery draws with Flutter’s own engine, the same one drawing the rest of your UI.</li><li><strong>First load.</strong> No pages, scripts or assets to download. Everything ships in the app bundle and works offline.</li><li><strong>Touch and frame rate.</strong> No extra layers between the finger and the frame. Gestures and frames are handled natively by Flutter.</li><li><strong>Score back to the app.</strong> No JavaScript bridge and string messages. You get a typed Dart GameResult from an await.</li><li><strong>Look and feel.</strong> No separate web styling to keep in sync. The game uses your app’s Theme, font and colors.</li><li><strong>Who maintains it.</strong> Not a separate web team: the same Flutter team, the same CI.</li></ul><p>That is the core idea: campaign games get the same native performance as the rest of your app, and the integration is one awaited function call instead of a bridge.</p><h3>Four games, four Flame techniques</h3><p>Every game package has the same inside: src/logic/ is pure Dart with unit tests, src/component/ holds one Flame component per file, and src/game/ only orchestrates.</p><h3>Fruit Slice · flame_gallery_slice</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/320/0*kC9dzCkwwDQaW9qK.gif" /></figure><p>Swipe to slice fruit, slice three or more in one stroke for a combo, avoid the bomb.</p><ul><li><strong>Launch physics.</strong> LaunchPlanner picks an apex between 15% and 45% of the screen height, sets gravity to g = 1.2 · h · speed² and the launch speed to v = √(2gh). Harder levels make fruit faster, but it always reaches the same height.</li><li><strong>Cutting.</strong> Every segment of the swipe is tested against each fruit as a segment–circle intersection, so a fast swipe never skips a fruit between two frames.</li><li><strong>Halves.</strong> A sliced fruit is the same sprite cut in two with srcPosition; each half keeps the fruit’s motion and drifts away from the cut line.</li><li><strong>Look.</strong> A blade trail that thins and blurs as it ages, juice bursts from the shared GalleryBurst effect.</li></ul><h3>Ruins Runner · flame_gallery_runner</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/320/0*b6ylS9NYJ3NoMTm0.gif" /></figure><p>Top-down, three lanes. Swipe left or right to change lane, up to jump, down to slide, collect coins while the world speeds up.</p><ul><li><strong>Fixed time step.</strong> The simulated step is capped at 1/30 s. Flame does not cap dt, so one janky frame could otherwise move an obstacle straight through the player.</li><li><strong>Fair rows.</strong> ObstacleRowPlanner always leaves at least one passable lane, verified with 50 seeds × 200 rows in tests. Collision is a table of obstacle type against player pose.</li><li><strong>Feel.</strong> A jump and a slide can cancel each other, so back-to-back rows stay passable even at top speed. A short invulnerability window follows every hit.</li></ul><h3>Sweet Swap · flame_gallery_match</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/320/0*ExjYU7Orj80D4qSK.gif" /></figure><p>Swap neighbouring candies; three or more in a row clear, and chain reactions score ×2, ×3. Move limit, timer and target score are all optional.</p><ul><li><strong>Pure board.</strong> MatchBoard is plain Dart: matches, gravity, refill and chains come out as a list of CascadeStep records, fully deterministic for a given Random.</li><li><strong>Stage queue.</strong> Flame plays those steps through a queue where every stage returns its own duration. Input is locked while the queue is busy.</li><li><strong>No lost points.</strong> End conditions are checked only after the chain settles, so a timer that runs out mid-cascade never eats points already earned.</li><li><strong>Daily challenge.</strong> A seed gives every player the same board.</li></ul><h3>Note Rush · flame_gallery_rhythm</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/320/0*Bv1qbBssAh6hv2nc.gif" /></figure><p>Notes fall down four lanes; tap when they reach the line. Perfect ≤ 60 ms, Great ≤ 120 ms, Good ≤ 180 ms, combos up to ×4.</p><ul><li><strong>Song clock.</strong> Note positions are computed every frame from the song clock instead of summing dt, so they never drift. The clock syncs to the audio player’s position each frame.</li><li><strong>Bring your own audio.</strong> The library only defines a RhythmAudio interface. audioLatency compensates for headphone delay; the example app plugs in flame_audio.</li><li><strong>Input.</strong> Lanes take taps independently, so multi-touch works.</li></ul><h3>Quick start</h3><pre>flutter pub add flame_gallery</pre><p>Only need one game? Each one is its own package, for example flame_gallery_slice.</p><pre>import &#39;package:flame_gallery/flame_gallery.dart&#39;;<br><br>final result = await FlameGallery.show(<br>  context,<br>  game: FruitSlice(<br>    config: const SliceConfig(timeLimit: Duration(seconds: 30), bomb: null),<br>  ),<br>  onEvent: (event) =&gt; analytics.log(&#39;$event&#39;),<br>);<br>if (result case GameResult(isCompleted: true, :final score) when score &gt;= 50) {<br>  grantCoupon();<br>}</pre><p>The result carries the score, play time, how the game ended and per-game stats. Stats are read through const keys, never string literals:</p><pre>final sliced = result.stats[SliceStatKeys.sliced];<br>final endedBy = result.stats[GameStatKeys.endedBy]; // &#39;timeUp&#39;, &#39;bomb&#39;, &#39;quit&#39;...</pre><p>onEvent streams GameStarted, GameScoreChanged, GamePaused, GameResumed and GameFinished. GameResult.toJson() gives a flat map for analytics or a platform channel.</p><h3>Make it your brand, without forking</h3><p>Everything a company wants to change is config: colors, font, logo, background, texts, sprites and rules. Pass no colors and the game simply follows your app’s theme.</p><pre>FlameGallery.show(<br>  context,<br>  game: FruitSlice(<br>    theme: GameTheme(<br>      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF00897B)),<br>      logo: const AssetImage(&#39;assets/brand_logo.png&#39;),<br>      background: const GameBackground.gradient(<br>        LinearGradient(colors: [Color(0xFF004D40), Color(0xFF00897B)]),<br>      ),<br>    ),<br>    strings: FruitSlice.stringsTr.copyWith(title: &#39;Summer Campaign&#39;),<br>  ),<br>  overlays: GameOverlayBuilders(<br>    gameOver: (context, ui) =&gt; MyResultSheet(<br>      score: ui.result()?.score ?? 0,<br>      onAgain: ui.start,<br>      onClose: ui.close,<br>    ),<br>  ),<br>);</pre><ul><li><strong>Your own sprites.</strong> GameAsset(&#39;assets/products/can.png&#39;) swaps any image; GameAnimation turns a horizontal sprite strip into an animation.</li><li><strong>Your own menus.</strong> Replace the start, HUD, pause or game over overlay; ui hands you the strings, the score and the actions.</li><li><strong>Your own sounds.</strong> The library plays nothing itself. It emits cues (coin, hit, slice, noteHit…) and you map them to whatever audio package your app already uses.</li><li><strong>Your own difficulty.</strong> Presets like SliceDifficulty.hard stay, and copyWith tunes any number on top of them.</li></ul><h3>Architecture: how it fits together</h3><h3>Packages and their borders</h3><p>The repo is a pub workspace managed with Melos. Every game is its own package; all of them depend on one core and never see each other. Most host apps only add the facade.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Jhwed2h83TctOf9JrxrOuw.png" /></figure><p>Library packages depend only on flutter and flame: no bloc, freezed, get_it or audio package, so a host app never inherits our version choices. State lives in private ValueNotifiers exposed as read-only ValueListenables.</p><h3>A two-layer game contract</h3><ul><li>GalleryGame&lt;G&gt;<strong>, for the host app.</strong> An immutable recipe: config, GameTheme, GameStrings. createGame(colors) builds a fresh Flame game for every session and buildHud builds the HUD. FruitSlice, SweetSwap and the others are these.</li><li>GalleryFlameGame<strong>, for the game engine.</strong> Owns the phase machine, the score, the overlays and the event stream. A game only implements onRoundStart(), calls addScore() and finish(), and adds stats in collectStats().</li></ul><p>Only the core changes the phase. Games check isPlaying before scoring or penalising, so a late event after the round has ended can never change the score.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GpfLclyPyjOTrcGzNCQfBA.png" /></figure><h3>One session, start to finish</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9Xb7lGCbW00stVbrkDJHZA.png" /></figure><p>The event stream is synchronous on purpose: the quit result raised while the game is being removed still reaches the host before it unsubscribes.</p><h3>How graphics are managed</h3><p>A library that runs inside someone else’s app cannot leak memory, fight over a global cache or drain the battery. These are the rules every game follows.</p><ul><li><strong>A private image cache per game.</strong> GalleryFlameGame gives each game its own Images(prefix: &#39;&#39;). The global Flame.images is never touched, so other Flame games in the host app are not affected.</li><li><strong>The cache is cleared on close.</strong> Flame only clears images in dispose(), and GameWidget never calls it. The core clears the cache in onRemove, so opening a game fifty times does not grow memory fifty times.</li><li><strong>One way to name an asset.</strong> GameAsset(&#39;assets/my_fruit.png&#39;) (plus an optional package) becomes a bundle key. The same type points at our placeholder sprites or at the host’s product images.</li><li><strong>Parallel loading.</strong> loadSprites(assets) loads every sprite with Future.wait and keeps their order.</li><li><strong>Animations from strips.</strong> GameAnimation turns a horizontal sprite strip into a SpriteAnimation: frame count and step time, nothing else.</li><li><strong>Backgrounds behind the canvas.</strong> GameBackground is a sealed type (color, gradient or image). The host page draws it behind the GameWidget, and games return resolveBackground(colors.surface) from backgroundColor(), which turns transparent when a backdrop exists.</li><li><strong>Shared effects.</strong> Juice bursts and floating “+10” labels come from GalleryBurst and FloatingLabel in core. TextPaints are created once in onLoad, never per label.</li><li><strong>Two random sources.</strong> Particles use a separate _effectsRandom. The injected Random drives gameplay only, so a seed reproduces the same spawns however the player plays.</li><li><strong>Battery.</strong> The engine pauses 0.8 s after game over: enough for the last effects to finish, then frames stop.</li><li><strong>System UI.</strong> Games run in immersiveSticky and restore edgeToEdge on close; immersive: false opts out.</li><li><strong>IP-clean art.</strong> Mechanics are free, names and art are not. Every sprite and the music are generated placeholders, so companies can ship them or swap in their own.</li></ul><h3>Technical decisions and why</h3><ul><li><strong>One package per game.</strong> A company adds only the game it needs; games cannot break each other; shared needs go to core.</li><li><strong>Minimal dependencies.</strong> No version clashes with the host app. Even audio is an interface; the example app wires it with flame_audio.</li><li><strong>Pure Dart logic layer.</strong> Physics, rules and board logic are tested in milliseconds, without Flame.</li><li><strong>Immutable config + </strong>copyWith<strong>.</strong> Campaign variants are easy to derive. Nullable fields take a ValueGetter: copyWith(bomb: () =&gt; null) removes the bomb.</li><li>@internal<strong> entry points.</strong> Game classes are exported for the HUD, but methods like swipe or spawnWave stay closed outside the package.</li><li>final class<strong> everywhere.</strong> Concrete classes are final, contracts are abstract base or abstract interface, event families are sealed.</li></ul><h3>Testing a game engine, for real</h3><ul><li><strong>Rules, with </strong>test<strong>.</strong> Launch physics, slice geometry, combos, the row planner’s guarantee, board generation and cascades, timing windows, the song timeline.</li><li><strong>Game flow, with </strong>flame_test<strong>.</strong> Phases, score, end conditions, sound cue order; Random(seed) makes every run repeatable.</li><li><strong>End to end, with Maestro.</strong> All four games on an iPhone 17 simulator and a Pixel 7a emulator: menus, pause, HUD values, exact scores, the result returned to the host.</li></ul><p>Menus and HUD values carry Semantics(identifier:) ids (GalleryKeys, RunnerKeys, MatchKeys, RhythmKeys), so tests look for ids, not text, and work in any language. On the seeded Sweet Swap board, one specific swap is verified end to end to trigger a three-level chain worth exactly 210 points.</p><h3>Lessons from testing a Flame game with Maestro</h3><ul><li>A running game is never “idle”, and every element lookup waits for idle: 1–3 s on iOS, about 10 s on Android. HUD checks run while the game is paused.</li><li>On Android, a swipe that starts at the screen edge becomes the system back gesture (pause, then close). Test swipes start between 12% and 88% of the width.</li><li>The first switch to immersive mode shows a one-time Android hint; a shared sub-flow taps it away.</li></ul><h3>From merge to pub.dev</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LjRHAOzlP1y04g7JAnSHZg.png" /></figure><p>A release is a version bump plus a CHANGELOG entry. When CI is green on main, release.yml tags every package whose version is not on pub.dev yet (flame_gallery_core-v0.2.0) and runs publish.yml on each tag in order. Publishing uses pub.dev’s GitHub OIDC, so no token lives in the repo. It runs flutter pub publish, because the stock dart-lang/setup-dart workflow cannot publish packages that depend on the Flutter SDK.</p><h3>The packages on pub.dev</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0uaai_ztdn8aG3bARyO_2w.png" /></figure><ul><li><a href="https://pub.dev/packages/flame_gallery">flame_gallery</a>: the facade, FlameGallery.show and every game. Start here.</li><li><a href="https://pub.dev/packages/flame_gallery_core">flame_gallery_core</a>: contracts, host page, menus. Use it to build your own game.</li><li><a href="https://pub.dev/packages/flame_gallery_slice">flame_gallery_slice</a>: Fruit Slice</li><li><a href="https://pub.dev/packages/flame_gallery_runner">flame_gallery_runner</a>: Ruins Runner</li><li><a href="https://pub.dev/packages/flame_gallery_match">flame_gallery_match</a>: Sweet Swap</li><li><a href="https://pub.dev/packages/flame_gallery_rhythm">flame_gallery_rhythm</a>: Note Rush</li></ul><h3>What’s next</h3><ul><li>flame_gallery_native<strong>:</strong> native iOS and Android apps open the same games through Flutter add-to-app, with GameResult.toJson() as the payload back. A team without Flutter gets native-rendered mini games too.</li><li><strong>Android polish:</strong> keep the side-edge back gesture from interrupting play.</li><li><strong>Quality:</strong> golden tests for menus and HUD, and frame-time measurements in profile mode on physical devices.</li><li><strong>More games</strong> on the same core.</li></ul><p>Try it, break it and tell me what your campaign needs. Issues and pull requests are welcome.</p><ul><li>Play in the browser: <a href="https://vb10.github.io/flutter_flame_gallery/play/">vb10.github.io/flutter_flame_gallery/play</a></li><li>Website: <a href="https://vb10.github.io/flutter_flame_gallery/">vb10.github.io/flutter_flame_gallery</a></li><li>GitHub: <a href="https://github.com/VB10/flutter_flame_gallery">VB10/flutter_flame_gallery</a></li><li>pub.dev: <a href="https://pub.dev/packages/flame_gallery">flame_gallery</a></li></ul><p><a href="https://github.com/VB10/flutter_flame_gallery">GitHub - VB10/flutter_flame_gallery: Drop-in, brandable 2D mini games for Flutter apps, built on Flame: Fruit Slice, Ruins Runner, Sweet Swap (match-3) and Note Rush (rhythm). One call: FlameGallery.show(context, game: ...).</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6df3476482fb" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/flame-gallery-drop-in-mini-games-for-flutter-apps-6df3476482fb">Flame Gallery: Drop-in Mini Games for Flutter Apps</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Migrating A Flutter Project to Built In Kotlin]]></title>
            <link>https://medium.com/flutter-community/migrating-a-flutter-project-to-built-in-kotlin-e0e41f2ea11a?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/e0e41f2ea11a</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[gradle]]></category>
            <dc:creator><![CDATA[tomerpacific]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 15:42:03 GMT</pubDate>
            <atom:updated>2026-10-28T15:42:01.689Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DxQE1z8L3ZCIUEGi2Bsp8A.jpeg" /></figure><blockquote>🙌 TL;DR I created a dart script to automatically migrate your Flutter project with ease. You can find it at the bottom of this article.</blockquote><p>If your application or plugin supports Flutter version ≥ 3.44, then you might consider migrating it to use Built In Kotlin support. This is needed in order to support Android Gradle Plugin (AGP) versions ≥ 9. If you are not supporting Flutter version ≥ 3.44 or if your project is not currently using AGP ≥ 9, you don’t need to do this right now, but you might want to consider it.</p><p>I have undergone this migration process with <a href="https://play.google.com/store/apps/details?id=com.tomerpacific.birthday_calendar">one of my applications</a>, and would like to share the process I had to go through, so you may have an easier going though it.</p><blockquote>✋ If you have a Flutter plugin that is a pure Dart plugin (meaning no native Android code), then you don’t need to do anything regarding the migration and you can leave it as is.</blockquote><h4>Setting up Flutter ≥ 3.44</h4><p>The first thing you need to do in order to migrate your application/plugin is to make sure you are on a Flutter version that supports it. That means, to upgrade your local Flutter version to at least 3.44. Before you upgrade the version, make sure you are on the stable channel in Flutter. You can do so by opening up a terminal and typing:</p><pre>flutter channel</pre><p>If the output shows you that you are on the stable channel, you can proceed to upgrade by typing:</p><pre>flutter upgrade</pre><p>This will upgrade your Flutter version to the latest one in the stable channel.</p><p>After doing this, we need to also declare in our <strong>pubspec.yaml</strong> file what our minimum supported Flutter version is:</p><pre>environment:<br>  sdk: ^3.2.0<br>  flutter: &#39;&gt;=3.44.0&#39;</pre><h4>Upgrading AGP ≥ 9 (the manual approach)</h4><p>To upgrade the Android Gradle Plugin version to be ≥ 9, you need to open the android folder of your Flutter project. Make sure you have at least <strong>Android Studio Otter 3</strong> since that is the first version that supports AGP 9. Once you have opened the project, you can use the AGP Upgrade Assistant under the Tools menu option.</p><p>Now, there are several changes you need to make in the project.</p><ol><li>In the <strong>build.gradle</strong> file located under android/app, remove the</li></ol><pre>id “org.jetbrains.kotlin.android”</pre><p>in the plugins block. So this:</p><pre>plugins {<br>    id &quot;com.android.application&quot;<br>    id &quot;org.jetbrains.kotlin.android&quot;<br>    id &quot;dev.flutter.flutter-gradle-plugin&quot;<br>}</pre><p>becomes this:</p><pre>plugins {<br>    id &quot;com.android.application&quot;<br>    id &quot;dev.flutter.flutter-gradle-plugin&quot;<br>}</pre><p>2. Change this block:</p><pre>kotlinOptions {<br>        jvmTarget = &#39;17&#39;<br>    }</pre><p>to</p><pre>kotlin {<br>    compilerOptions {<br>        jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17<br>    }<br>}</pre><p>Moving on to the<strong> gradle.properties</strong> file:</p><p>3. Remove the</p><pre>android.defaults.buildfeatures.buildconfig=true</pre><p>4. Add the following attributes</p><pre>android.builtInKotlin=true<br>android.newDsl=false</pre><h4>Not Done Yet (Dependencies)</h4><p>It might seem like your past the migration, but there is one more step you need to do. If your Flutter project has any dependencies, you need to make sure those dependencies support built in Kotlin as well. So what you need to do is:</p><ul><li>Go over each dependency and see if there is a version for it that has built in Kotlin support</li><li>If it doesn’t have one, you have two choices: Either swap it out with another dependency that gives you the same functionality but has built in Kotlin support, or don’t migrate your Flutter project until this dependency does</li></ul><h4>Automation</h4><p>At this day and age, why would we want to do anything by hand? There’s no reason for it. That is why I created a pure Dart script you can run on your project to get the necessary output of what needs to be done.</p><p>When running it on a Flutter project of mine that was not migrated, I got the following output:</p><pre>[TODO] Project &quot;pill&quot;<br>       Path: PROJECT_PATH<br>       Type: Flutter app<br>       Result: Migration needed<br>       - AGP: 8.9.1   Kotlin plugin version: 2.1.0   Gradle: 8.11.1<br>       - android.builtInKotlin: not set   android.newDsl: not set   Kotlin source files: 1<br>       - Kotlin plugin applied at android\app\build.gradle.kts:3<br>       - Kotlin plugin declared (apply false) at android\settings.gradle:22<br>       - Kotlin plugin declared (apply false) at android\settings.gradle.kts:23<br>       - Dependency audit: 2 Android build file(s) checked; 1 package(s) apply the Kotlin plugin unconditionally, 0 only inside a condition.<br>       Blockers:<br>         * shared_preferences_android-2.4.23: build.gradle.kts:34   id(&quot;kotlin-android&quot;)<br>       Proposed edits (nothing changes until you approve):<br>         1. Remove the applied Kotlin plugin<br>              android\app\build.gradle.kts<br>                line 3:  - id(&quot;kotlin-android&quot;)<br>         2. Remove &quot;android.defaults.buildfeatures.buildconfig=true&quot; (rejected by AGP 9)<br>              android\gradle.properties<br>                line 4:  - android.defaults.buildfeatures.buildconfig=true<br>         3. Add android.builtInKotlin=true<br>              android\gradle.properties<br>                after line 6:  + android.builtInKotlin=true<br>         4. Upgrade AGP 8.9.1 -&gt; 9.0.1<br>              android\settings.gradle<br>                line 21:  - id &quot;com.android.application&quot; version &#39;8.9.1&#39; apply false<br>                            + id &quot;com.android.application&quot; version &#39;9.0.1&#39; apply false<br>         5. Upgrade AGP 8.11.1 -&gt; 9.0.1<br>              android\settings.gradle.kts<br>                line 22:  - id(&quot;com.android.application&quot;) version &quot;8.11.1&quot; apply false<br>                            + id(&quot;com.android.application&quot;) version &quot;9.0.1&quot; apply false<br>         6. Upgrade the Gradle wrapper 8.11.1 -&gt; 9.3.1 (AGP 9 requires a newer Gradle)<br>              android\gradle\wrapper\gradle-wrapper.properties<br>                line 3:  - distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip<br>                            + distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip<br>         7. Raise the Kotlin version pin 2.1.0 -&gt; 2.3.20 (Flutter rejected anything below 2.2.20 when I checked) [optional]<br>              android\settings.gradle<br>                line 22:  - id &quot;org.jetbrains.kotlin.android&quot; version &quot;2.1.0&quot; apply false<br>                            + id &quot;org.jetbrains.kotlin.android&quot; version &quot;2.3.20&quot; apply false<br>       Manual steps (this tool does not do these):<br>         1. Replace the kotlinOptions block at android\app\build.gradle.kts:18 with a top-level block (outside android { }):<br>           kotlin {<br>               compilerOptions {<br>                   jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17<br>               }<br>           }<br>         (Not automated: where this block belongs depends on your file.)<br>         2. Upgrade or replace the plugins listed under &quot;Blockers&quot;, or fall back to android.builtInKotlin=false on AGP 9 with the Kotlin plugin kept.<br>       Note: Both settings.gradle and settings.gradle.kts exist in android. Gradle only uses one of them, so a stale duplicate can make this report misleading and doubles the proposed edits. Check which one is real and consider deleting the other.<br>       Note: Keep the Kotlin plugin declaration with &quot;apply false&quot; in settings.gradle: on AGP 9 it pins the Kotlin version.<br><br>Warning: git reports uncommitted changes in:<br>  PROJECT_PATH<br>Committing or stashing first makes the edits easy to review.<br>Continue anyway? [y/N]: y<br>Apply all 6 proposed edit(s)? [y]es / [n]o / [s]elect one by one: y<br><br>Optional: Raise the Kotlin version pin 2.1.0 -&gt; 2.3.20 (Flutter rejected anything below 2.2.20 when I checked)<br>  android\settings.gradle<br>    line 22:  - id &quot;org.jetbrains.kotlin.android&quot; version &quot;2.1.0&quot; apply false<br>                + id &quot;org.jetbrains.kotlin.android&quot; version &quot;2.3.20&quot; apply false<br>  Apply this optional edit? [y/N]: y<br>  updated android\app\build.gradle.kts<br>  updated android\gradle.properties<br>  updated android\settings.gradle<br>  updated android\settings.gradle.kts<br>  updated android\gradle\wrapper\gradle-wrapper.properties<br><br>Changed 5 file(s).<br>Backup of the originals: C:\Users\tomer\Documents\Projects\Pill/.builtin_kotlin_backup/2026-10-23T11-13-59<br>To undo, copy the backed-up files back over the originals, or use git. Delete the .builtin_kotlin_backup folder when you are done.<br>Next: run &quot;flutter clean&quot;, &quot;flutter pub get&quot;, then &quot;flutter run&quot;.</pre><p>It also automatically detects if your Flutter project is a plugin or an application and only proceeds to make changes based on your approval.</p><p>And after the migration, you will see:</p><pre>Re-checking after the edits:<br><br>[WARN] Project &quot;pill&quot;<br>       Path: PROJECT_PATH<br>       Type: Flutter app<br>       Result: Migrated, but blocked by dependencies that still apply the Kotlin plugin<br>       - AGP: 9.0.1   Kotlin plugin version: 2.3.20   Gradle: 9.3.1<br>       - android.builtInKotlin: true   android.newDsl: not set   Kotlin source files: 1<br>       - Kotlin plugin declared (apply false) at android\settings.gradle:22<br>       - Kotlin plugin declared (apply false) at android\settings.gradle.kts:23<br>       - No Kotlin plugin is applied and builtInKotlin=true on AGP 9.0.1: already migrated.<br>       - The Kotlin plugin declaration with &quot;apply false&quot; is a version pin. That is expected here: it keeps the Kotlin version at or above Flutter&#39;s minimum.<br>       - Dependency audit: 2 Android build file(s) checked; 1 package(s) apply the Kotlin plugin unconditionally, 0 only inside a condition.<br>       Blockers:<br>         * shared_preferences_android-2.4.23: build.gradle.kts:34   id(&quot;kotlin-android&quot;)<br>       Manual steps (this tool does not do these):<br>         1. Upgrade or replace the plugins listed under &quot;Blockers&quot;, or fall back to android.builtInKotlin=false on AGP 9 with the Kotlin plugin kept.<br>       Note: Both settings.gradle and settings.gradle.kts exist in android. Gradle only uses one of them, so a stale duplicate can make this report misleading and doubles the proposed edits. Check which one is real and consider deleting the other.<br><br>Summary: 1 project(s) analyzed, 1 need attention.</pre><p>You can find it <a href="https://gist.github.com/TomerPacific/1015f2e84fa0a5350fca5163ad229ea6">here</a> →</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8db5c085c2d60e16de1d2c9f4332af67/href">https://medium.com/media/8db5c085c2d60e16de1d2c9f4332af67/href</a></iframe><p>If you would like to read other articles I have written, you can see them here →</p><p><a href="https://github.com/TomerPacific/MediumArticles">GitHub - TomerPacific/MediumArticles: A repository that contains code associated with various Medium articles I have written</a></p><h4>References:</h4><ul><li><a href="https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin">Documentation</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e0e41f2ea11a" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/migrating-a-flutter-project-to-built-in-kotlin-e0e41f2ea11a">Migrating A Flutter Project to Built In Kotlin</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your widget tree is already a list of options: where Jev fits in a Flutter project]]></title>
            <link>https://medium.com/flutter-community/your-widget-tree-is-already-a-list-of-options-where-jev-fits-in-a-flutter-project-ee519ee0571f?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/ee519ee0571f</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[e2e-testing]]></category>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[type-safe]]></category>
            <category><![CDATA[jevs]]></category>
            <dc:creator><![CDATA[Yii Chen]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 14:53:48 GMT</pubDate>
            <atom:updated>2026-10-14T14:53:47.547Z</atom:updated>
            <content:encoded><![CDATA[<h4>A model that picks from a list and cannot write text. What it can do for Flutter, and when you should not use it</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*9WmzO94yWJDvagTz.png" /></figure><p>If you have written E2E tests for a Flutter app, you have probably run into this: someone renames one widget key or changes a button label, and a whole row of tests turns red. Then the time goes into fixing test scripts instead of real problems in the app.</p><p>In mid-September, TypeSafe AI released <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">Jev</a>, a model made only for decisions. It works differently from a normal LLM. We first list the possible answers as options, then it picks one of them and tells us how sure it is, but it never generates any text.</p><p>When I saw this design, the first thing I thought of was Flutter. Every widget on a screen that you can tap or type into is already part of a limited list, so if we give that list to Jev, it should be able to decide the next step of an E2E test, and when the UI renames something, it can read the new name from the current screen. For the past few weeks I connected it to the E2E tests of a Flutter app and ran it many times, and in this article I want to share the process and the results with you.</p><p>Here is the conclusion first. In E2E tests, letting Jev pick every step, letting an LLM write only the text, and asking the LLM to referee when Jev cannot decide, was the best value combination I measured:</p><ul><li>Correct → 18/18, the same as an agent that uses an LLM for everything</li><li>Robust → 12/12 after the UI renames</li><li>Fast → 1.7x faster than the all-LLM agent</li><li>Cheap → 3.5x cheaper than the all-LLM agent, with zero false passes</li></ul><p>The rest of this article covers what Jev can do for Flutter, how it differs from a normal LLM (not quite what I expected), how to combine the two, and which situations did not work well in my tests. All numbers were measured on my own machine.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*jQASYma-ZI6zX-U0.png" /></figure><p>A few terms used in this article:</p><ul><li>marionette → an <a href="https://github.com/leancodepl/marionette_mcp">open-source tool</a> from LeanCode that lets an outside program read and control a running Flutter app</li><li>E2E test → a program operates the app automatically and goes through the flow a real user would</li><li>agent → the model looks at the screen and decides the next step by itself, instead of following a script</li></ul><h3>How the widget tree connects to Jev</h3><p>First, the overall architecture. The app, the runner and the judging all run locally. Only two kinds of request leave the machine: asking Jev for the next step, and asking an LLM to write text or to referee.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UMiVIPQ3h7UsNm0R.png" /></figure><p>From a running app, marionette can read each interactive widget’s type, key, visible text, enabled state and position. I turn that into a list like this:</p><pre>@e1 TextField key=email_field = (empty)<br>@e2 TextField key=password_field = (empty)<br>@e3 CheckboxListTile key=remember_me<br>@e4 FilledButton key=signin_button<br>@e5 TextButton key=forgot_password_button</pre><p>This list goes into the request to Jev almost unchanged:</p><pre>body = {<br>    &quot;model&quot;: &quot;jev-latest&quot;,<br>    &quot;state&quot;: state,                      # screen + task + actions taken so far<br>    &quot;questions&quot;: {<br>        &quot;nextAction&quot;: {<br>            &quot;type&quot;: &quot;choice&quot;,<br>            &quot;instructions&quot;: &quot;Pick an action that moves the task forward&quot;,<br>            &quot;criteria&quot;: {a.id: a.description for a in actions},<br>        },<br>        &quot;taskComplete&quot;: {&quot;type&quot;: &quot;noul&quot;,<br>                         &quot;instructions&quot;: &quot;Looking at the screen only, is the task done?&quot;},<br>    },<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*SRmIRk6TIEPLax4M.png" /></figure><p>The important line is criteria. Its value is the list of actions read from the screen, in the form {action id: what this action does}, and the model can only answer with one of those keys.</p><p>I also really like that questions can hold several questions at once. The request above asks two things: &quot;where to tap next&quot; is a choice question, and &quot;is the task already done&quot; is a noul question that returns a judgment from 0 to 1. One round trip gives us two answers. The &quot;is it done&quot; question matters a lot later, because it decides whether the whole test can report a false success.</p><p>Of course, there is still a layer of code I wrote in the middle, and it is not short. By default marionette returns every property of every widget, including the controller, decoration, colorSpace and so on. A sign-in screen with only nine elements is already 5,488 characters, about 1,370 tokens. After converting it into the format above, it is only 337 characters, about 85 tokens, which is 94% smaller. The description of each action is also generated by the code.</p><p>At first I thought Jev would at least save us from handling hallucinations, because an LLM might invent a button that does not exist, while Jev can only answer with a key from the list. After testing, this advantage turned out to be smaller than I thought. As long as the LLM uses structured output and its answer is limited to the keys of the same list (Gemini’s responseSchema supports enum), it never picked a nonexistent action in 749 decisions, with zero retries. So for hallucinations, both sides can rely on the type system.</p><p>What really makes Jev different is the probability it returns. There is an experiment about this later, and it was the result that surprised me the most.</p><p>The same pattern appears in other places. When GenUI decides which widget to use for the next screen, it also picks one from a catalog of widgets. In the next section I simulate this step and measure it, without using the real GenUI. Support ticket classification and PR risk levels in CI look similar too, but I have not actually tested those two. My own rule is that if the range of possible answers can be listed, this approach probably works.</p><h3>Separate deciding from producing</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Hn8cg61Kqv2kBhFQ.png" /></figure><p>This section started from <a href="https://x.com/AbdallahSh07/status/2101719364450046351">a demo</a> that Abdallah Shaban shared on X: a Flutter GenUI screen where every widget was picked by Jev from a widget catalog, and he said it felt as fast as running everything locally. Someone asked under the post, “where does the text in the widgets come from?” In this section I first break down that “fast” and measure it. Where the text comes from is answered later, in the “Let Jev pick, let the LLM write” section. Below are the measurements; the catalog and the data are my own.</p><p>I did not use Flutter’s genui package. I only simulated the step where it picks a widget: I wrote my own widget catalog, let the models output the A2UI format GenUI uses, and measured what they returned and how long it took. With the same catalog and twelve real chat turns, each run three times, I compared three approaches:</p><ul><li>Gemini writes the full UI description JSON</li><li>Gemini only returns the name of one widget</li><li>Jev picks one from the same catalog</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0Z81FCllixIzChfB.png" /></figure><p>The “only return a name” group is one I added on purpose. Without it, “generating vs picking” and “an LLM vs a model that cannot write” would be mixed together as two variables, and the comparison would not tell us much.</p><p>The results can be read in two parts. Letting the model decide and produce JSON at the same time costs an extra 600 ms, and in this sample its agreement was also lower, 75% against 83%. Tying deciding and producing together does not help either side. The other part is that when both only pick a name, Jev is 3.4x faster than the small model.</p><p>But I would not say Jev is more accurate. In this round it actually got five fewer answers right, 25/36 against 30/36. Most of the misses were in two turns: for “where is my refund” it picked the order list, and for “what’s in my cart” it twice picked a plain text reply. Twelve turns run three times are really only twelve questions, so this gap is not enough to say which one is more accurate, but we also cannot say they are the same. What is certain is the speed, more than three times faster.</p><p>There was also one turn that all three approaches “got wrong”, and later I felt I had labeled it wrong myself. The user said “I got the wrong size”, and all three picked the order list instead of the return form. Thinking about it again, they were right: the user did not say which order, so showing the list first is reasonable. I prefer to list disagreements like this instead of counting them as errors.</p><h3>After a UI rename: scripts versus agents</h3><p>Let’s start with the most direct example. In the demo app, I renamed the key nav_orders to nav_orders_v14. The hand-written test script could not find the widget and failed at step 4, while the agent running the same flow finished all 13 steps. Here is its log:</p><pre>step 4: Tap @e7, the NavigationDestination nav_orders_v14<br>step 7: Tap @e2, the FilledButton start_return_button_v14<br>step 8: Tap @e3, the FilledButton return_next_button_v14</pre><p>Nobody told the agent that the name had changed. It read the new key from the current screen and used it directly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*V99u3tTUQjXcVzGe.png" /></figure><p>I added these changes to the demo app on purpose. The app has a switch that takes a number (called a seed in the code; the same number always produces exactly the same changes, so the script and the agent face the same set of changes). It renames about two in five widget keys, swaps a few button labels and shuffles some lists, which is the kind of UI change a team makes in a normal week of development.</p><p>We need to be clear about what this example proves. A hand-written test hard-codes the UI’s implementation details, and an agent does not, so when the UI changes, the script has to be updated and the agent does not. An LLM agent can do the same, just slower and at a higher cost. I measured this too: with the same four flows and the same value table, only changing the deciding model from Jev to gemini-3.1-flash-lite, both were 12/12, and the LLM was 2.7x slower and 4.7x more expensive.</p><p>After expanding to 4 sets of changes with 3 runs each, the results are:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ft1yt1N6bpOvj3aV.png" /></figure><p>The script failing every time was what I expected. The surprise was that “Jev only” got just 6/12. Its choices were right. It stopped on seeds 7 and 91 because those two seeds renamed the email field, from email_field to email_field_v17, while my hand-written value table still looked values up by the old key. With no value found, Jev could tap the field but could not type into it, its confidence dropped, and it stopped before the threshold.</p><p>In other words, a value table is also a kind of selector. When the UI is renamed it breaks as well, just in a safer way. The two approaches where the LLM writes text from the current screen do not have this problem, and the one where Jev does the picking is 1.9x faster and 4.3x cheaper. Its one miss has nothing to do with the rename. It was the navigation stop I explain later, and with the referee added it went to 12/12.</p><p>So in this example, the real value Jev brings is the risk-tiered threshold table later in this article, not just the fact that the test passed.</p><p>There is also one case I originally wanted to use as an example and later downgraded myself. In the first comparison, the hand-written support flow got stuck on contact_us_button. This button is below the FAQ list, outside the screen, so the script could not tap it. The agent passed, because at step 5 it typed refund into the search box, the list got shorter and the button appeared. That step was not in the script, and I had not thought of it either. However, the string refund came from my value table. Jev does not produce text; what it did was decide to use the search box to move forward. So this is more of an advantage of agents in general, not something special about Jev.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*h9LjD6jkkv3yX2_r.png" /></figure><p>This value table later became the bottleneck of the Jev-only approach, and the 6/12 above was caused by it. I explain later how I handled it.</p><h3>Why give the decisions to Jev: risk-tiered thresholds</h3><p>I got this part wrong at first. I set one global threshold: below a confidence of 0.55, stop instead of guessing. The model kept picking the right next step and kept getting blocked. Once it correctly chose to “fill in the password, then tap sign in” with a confidence of 0.45, and it was blocked.</p><p>Then I went back to the <a href="https://docs.typesafe.ai/confidence">official docs</a>, which explain it clearly:</p><blockquote><em>low confidence on a Choice often means none of the options are a clear winner over the others</em></blockquote><p>The confidence of a Choice shows how flat the probability distribution is, in other words how closely the options compete, which is a different thing from whether the answer is right. When two actions can both move the flow forward, a low confidence is actually an honest answer, and blocking it is the mistake.</p><p>After I set a different threshold for each kind of action based on its risk, the problem was solved:</p><pre>CONFIDENCE_FLOOR = {<br>    &quot;scroll&quot;: 0.20,   # just scroll back<br>    &quot;fill&quot;:   0.30,   # typing can be redone<br>    &quot;tap&quot;:    0.35,   # navigation can go back a page<br>    &quot;submit&quot;: 0.70,   # writes something the user will see<br>    &quot;finish&quot;: 0.60,   # a wrong &quot;done&quot; makes the whole test lie<br>}</pre><p>A wrong scroll can just be scrolled back, but a wrong “done” makes the whole test report a wrong result. These two cases should not share one threshold.</p><p>For this table to work, the probability itself has to mean something: decisions at a confidence of 0.7 should really be wrong less often than decisions at 0.3. TypeSafe says their probabilities are calibrated, but I did not verify this myself, so I do not treat it as a conclusion. Verifying it needs a correct answer key, and this task does not have one. What I can share is the result in practice: after switching to risk tiers, all 24 runs passed, while the old global 0.55 threshold blocked correct decisions. It is not as good as a calibration curve, but at least it is what I actually saw.</p><h4>Same wrong data, only one side reports a false pass</h4><p>Later I added an experiment to see whether this probability actually helps when something goes wrong.</p><p>The task was “report that order 5198 arrived damaged”. I deliberately did not update the value table, so it still held the values from another flow: order 5213, with the message “my parcel has not moved in four days”. Then Jev and gemini-3.1-flash-lite each ran it three times. Both got exactly the same screens, candidate actions and value table; only the deciding model was different. For judging, I did not look at what the agent reported. After each run I read the app&#39;s store directly to check whether a support ticket was sent and what it said.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*kplqrPs35jWxFqqU.png" /></figure><p>The LLM reported success all three times, and all three times it was wrong: it sent the wrong support ticket and still reported the task as done. Jev did not finish any of the three, but it did not write any wrong data either. Another task, “add a new address labeled Home”, went the same way, with Office in the value table. The LLM saved an Office address from the table and reported success, all three times. Jev filled in Office from the table, its confidence dropped to between 0.28 and 0.34, and it stopped.</p><p>Jev’s probability reflected that the input did not match the task, and the LLM’s self-reported confidence did not. This was the first time the risk-tiered table had experimental data behind it, not just “it passes when we use it”.</p><p>The scope of this experiment needs to be clear too. The temperature was 0, so three repeats are not three independent samples; it is closer to looking at the same situation three times. And it is only two tasks and one app, not a full calibration curve. But the direction is clear: a test that reports a false success is more dangerous than a test that fails, and that is exactly the difference here.</p><p>If you want to use this in a real project, I suggest spending half a day first: group the decisions you have run by confidence, label them right or wrong by hand, and see what the curve looks like. This is also the next thing I want to do.</p><p>The way I used to build tools was the opposite: let the model decide freely, then add checks afterward to catch hallucinations. Defining the options in code, letting the model only pick, and setting thresholds by risk is a different approach, and the threshold is a number you can tune, not a prompt.</p><p>When I reran everything with the new home screen, I ran into a real example. The new home screen has two equally correct ways to reach the orders page: the Orders tab at the bottom, and the “Your orders” shortcut. Jev’s confidence at this step was between 0.34 and 0.53, with the probability split across two correct options. Out of the 27 decisions the return flow made at this step, 3 landed exactly at 0.34, below the tap threshold of 0.35, so it stopped. It did not pick wrong and did not report a false pass; it just honestly showed that both ways were fine. This is also why thresholds should be tiered by risk: navigation can go back a page, so its threshold can be lower, or the screen can keep only one way. I did not tune the threshold to make the numbers look better, so that is where the no-referee results of 17/18 and 11/12 come from.How the referee closes those two gaps is in “When Jev cannot decide, ask the LLM to referee” below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*8jj1ku0yg_4q3TwE.png" /></figure><p>Following this, I also ran into a more basic problem. Once I saw a confidence of only 0.40, and my first thought was that the model was not strong enough. After checking, I found that the home screen had eight InkWell and GestureDetector widgets without keys, so the option list contained eight tap_gesturedetector_x_x_x_x entries. When the options themselves are unclear, the model&#39;s confidence cannot be high.</p><h3>Let Jev pick, let the LLM write</h3><p>We already saw the weakness of the Jev-only approach: it cannot write, so it cannot type anything the value table does not have. It got 0/6 on the two new tasks, although it safely stopped every time.</p><p>So I split the two jobs. Which action to take at each step is up to Jev, and only when a new input field appears do I call gemini-3.1-flash-lite once to write the text. Once for sign-in and once for a form, a full run calls the LLM only 2.7 times on average, and Jev handles all the other steps.</p><p>One step looks like this, with Jev and the LLM each appearing only in their own part:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*KisFdO8QNCM4w_A_.png" /></figure><p>This is what a real run of the combination looks like. The task is one sentence, “sign in and report that order 5198 arrived damaged”. Filled tags are actions Jev picked, outlined tags are text the LLM wrote after reading the screen, and the last frame shows the support ticket the app actually received:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/900/0*OLCtIhhSldqUx-Uv.gif" /></figure><p>The same task, “report that order 5198 arrived damaged”, run once with each of four approaches:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*b_va0bxo7AxMy8OL.png" /></figure><h4>When Jev cannot decide, ask the LLM to referee</h4><p>Earlier, on the new home screen, Jev could not decide between two correct ways and stopped at the threshold 3 times. To handle this, I did not change the threshold. Instead I added a seventh approach: when Jev is not confident enough on a step that can be undone (tap, scroll, typing), it does not stop, and flash-lite picks one from the same options instead. Submitting and judging “done” still stop, because low confidence there usually means the input data itself is wrong. I ran a separate batch of the six tasks plus the four UI changes, 30 runs in total. All of them finished with no false passes, at $0.00098 per run, almost the same as $0.00094 without the referee. The referee was called only once, on the renamed home screen at the step where confidence was 0.33. It picked the Orders tab and the flow finished. Only one tie came up in 30 runs, so part of this 30/30 is luck, but the direction is clear: when there is a tie, asking once more is a better deal than stopping.</p><h4>Comparing all seven approaches</h4><p>Six tasks (the original four flows plus two new tasks), three runs for each approach, judged by reading the store as before:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*d9x7feNXUbdiFFN2.png" /></figure><p>The sixth approach, the combination without a referee, got 17/18 and the all-LLM agent 18/18.The one miss was the navigation stop mentioned above, and it wrote no wrong data. The seventh approach with the referee closed the gap at 18/18. In return it is 1.7x faster and 3.6x cheaper. After the UI renames, the combination got 11/12 and the all-LLM agent 12/12, with the combination 1.9x faster and 4.3x cheaper, while the hand-written script got 0/12. Compared with the flagship model, the combination is 5.8x faster, 57x cheaper, and more accurate. Pro got 14/18: in the support flow it went to the tracking page of order 5213 all three times instead of the support tab, and finally reported that it could not finish. In a flow that moves forward one step at a time, more reasoning only makes it slower and more expensive, and the accuracy did not go up with it.</p><p>The first version of the combination actually missed once, because I wrote the prompt wrong. The task “add a new address labeled Office” did not mention a street, and I had told the writing model to “leave fields the task does not need empty”, so it left the required address field empty too. Jev’s confidence when saving was only 0.29, so it stopped and wrote no wrong data. After adding “for a required field the task does not mention, fill in a reasonable value”, it did not happen again, and all later numbers use the fixed version.</p><p>The hand-written script is still the fastest, as long as it is correct. For new tasks there is no script at all, and someone still has to write one.</p><p>This split follows the same idea as the GenUI section: writing goes to the model that can write, and only when needed. Picking each step and judging whether the task is done goes to the model that honestly tells you how unsure it is. When it cannot decide and the step can still be undone, we ask the model that can write to pick once.</p><h3>Before you start: the quality of your key names</h3><p>This part is specific to Flutter, and it is something I did not expect.</p><p>The agent’s action descriptions are generated from key names. Key(&#39;start_return_button&#39;) becomes &quot;Tap the FilledButton start_return_button&quot;, and the model can understand what that is. On the other hand, if the keys are named carelessly, or there are no keys at all, the agent can only match on visible text, so it breaks as soon as the copy changes and becomes as fragile as a hand-written script again. How well your keys are named directly affects how understandable the options are for the model.</p><p>One more detail, so you do not waste a round of testing like I did: changing only the key without changing the Semantics identifier is the same as not changing it, because the tool falls back to the identifier. The first time I ran the rename experiment it had no effect at all, and it took me a long time to find out why.</p><h3>When it is worth it: split the total time first</h3><p>To decide whether Jev fits a situation, my way is to split the total time first and see how much of it the model calls take.</p><p>For this project, the conclusion is not very good for Jev. One return flow takes 12.8 seconds. Split up: Jev made 18 decisions in 4.4 seconds (35%), 189 transport calls took 1.9 seconds (15%), and the remaining 6.5 seconds or so (51%) were spent waiting for the app’s own animations and loading.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*TlVXvjLX3uDxX1OW.png" /></figure><p>The model is only a bit over a third of the time. That means even if Jev took zero seconds, the whole test would only be about 1.5x faster, 1.53x by the breakdown above. The rest of the time goes to Flutter’s page transitions, waiting for the screen to settle, and the number of steps itself, none of which depends on the model’s speed.</p><p>At first I spent a whole round tuning parameters and the multiplier never showed up, because the part I was optimizing was only a third of the total. I should have split the time first and then decided what to tune, and I only realized this after I was done.</p><p>My rough rule is:</p><ul><li>The model is 80% of the time or more → the multiplier shows up directly, and switching is well worth it</li><li>The model is only a third → the ceiling is about 1.5x, and you will barely feel the difference</li><li>Somewhere in between → do the math yourself</li></ul><p>The best fit is a judgment that finishes in one call, such as classification, routing, scoring, filtering, or acting as a verifier for another model. It fits less well when the model is just one small step in a loop, like driving a UI, crawling web pages, or anything that waits for an outside system.</p><h4>The multiplier is real in single-call tasks</h4><p>To check this, I ran a separate single-call experiment, used for picking stories for my daily tech digest. Given one news item, it returns three typed judgments: whether to include it, which category it belongs to, and how technical it is. The material was 20 real Hacker News items fetched from the API, and each of the three models ran 40 times.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*rphFDskTKyT-51oy.png" /></figure><p>Compared with a flagship model that reasons, Jev is 19.5x faster and 220x cheaper. The gap comes mostly from 364 thinking tokens. The flagship model’s final answer is only 26 tokens, but before that it writes 364 tokens of thinking that the user never sees, and you still pay for the time and the cost.</p><p>The trade-off is agreement. On the same 20 items, Jev and the flagship model agreed on “include or not” 14/20 and on “which category” 13/20, and on both at the same time only 9/20. Neither side is a correct answer key. “Would a senior engineer want to read this” is a subjective judgment, and two people would not agree on everything either, so I can only talk about agreement, not accuracy.</p><p>The more complete way to say it: 19.5x faster, 220x cheaper, a third of the answers differ, and this experiment cannot tell which side is right.</p><h4>What E2E tests actually get</h4><p>Driving a UI is not where the multiplier lives, so what does Jev bring to E2E tests? First the numbers, four flows with three runs each, all 24 runs passed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*HkpMha_ZGQ97YMGS.png" /></figure><p>The script is faster on all four flows. The script already knows the answer to every step, while the agent has to ask the model at each step. As long as the script is correct, it will always be faster, and tuning parameters cannot change that.</p><p>I ran into the “as long as the script is correct” condition myself. After I moved the “Add to cart” and “View cart” buttons on the product page into a bar fixed at the bottom of the screen, the add-to-cart script became more than 6 seconds slower on every run. The reason was two scroll_to steps still left in the script. The buttons were already on screen, so scrolling changed nothing, and the runner kept waiting for the screen to change until it timed out. When a script no longer matches the UI, the result is not always a failure. Sometimes it just gets slower quietly, and the test still looks green.</p><p>So the benefit Jev brings to E2E is two things other than speed. The first is the rename example earlier: when the UI changes the script breaks and the agent does not, which happens almost every week in real projects. The second is that the cost is low enough to ignore. One full run costs $0.0007 on average, about 15,000 input tokens. Running four flows for each PR costs about $0.0026, twenty PRs a day is five cents, and a month is about one and a half US dollars. The median latency of each decision was between 221 and 236 ms, measured from Taiwan to the US West.</p><p>Agent tests used to be hard to put into CI mainly because they were slow and expensive. The slow part is still there, about 1.5x to 3x slower, but the cost is no longer a problem.</p><h3>How to start</h3><p>Putting it all together, among the agent approaches, the combination with the referee is the best value: the same accuracy as an all-LLM agent, much better speed and cost, and no false passes.</p><p>Jev no longer has a waitlist. I use an official TypeSafe API key: sign in at <a href="https://console.typesafe.ai">console.typesafe.ai</a> with Google or email, and create one under Settings → Keys. I recommend the official key first, because the direct connection is the fastest, and all the latency numbers in this article were measured on it, from Taiwan to the US West. If you cannot use an official key, you can also <a href="https://openrouter.ai/docs/guides/community/jev">call Jev through OpenRouter</a>, but the extra hop is much slower. In my tests a call through OpenRouter took 1.3 to 2.2 seconds, while calling TypeSafe directly took about 0.36 seconds.</p><p>If it were me, I would start like this:</p><ul><li>Use it first on flows whose UI changes often, places where hand-written scripts often break, and new flows nobody has written a script for yet. These are where an agent does better than a script: after the UI renames, the script got 0/12 and the combination with the referee got 12/12. Critical flows that are stable can stay as they are. As long as the script is correct, it is still the fastest (3.8 seconds) and free, so the two can live side by side.</li><li>Give each step’s choice and the “is it done” judgment to Jev, with thresholds set by the risk of the action: 0.70 for submit and 0.60 for judging done.</li><li>Call a cheap LLM to write only when a new input field appears, and remind it: “for a required field the task does not mention, fill in a reasonable value”. For steps that can be undone, like tap and scroll, if Jev is not confident enough, let the same LLM referee once.</li><li>When judging the result, read the app’s state directly instead of trusting the pass the agent reports. That is how the three false successes above were caught.</li></ul><p>The cost is about $0.001 per full run. Twenty PRs a day with four flows each comes to about 8 cents.</p><p>There are also a few things to watch out for. I ran into thirteen problems along the way, and half of them came from the tools themselves. A hot restart replaces the isolate, so a persistent connection keeps holding a tree that no longer exists. Elements outside the screen do not appear in the list at all, rather than being marked as invisible. A SnackBar removes the buttons under it from the widget tree completely until it closes. And on macOS, when the app window is fully covered by another window, it stops drawing, so the agent reads an empty screen and every approach fails at the same place, which is easy to mistake for a model problem.</p><p>The sample size also needs to be clear: one app, three runs for each case, temperature 0. If you want to use these results to convince your team, I suggest running it on your own app first.</p><h3>Conclusion</h3><p>What this experiment made me more sure of is that a Flutter widget tree and a model like Jev, which only makes choices, work well together. Adding an LLM to write text and to referee when Jev cannot decide covers accuracy, robustness, speed and cost at the same time. It does not make E2E tests faster than scripts, but it makes them handle UI changes better, and it does not report false successes.</p><p>The code is open source, including the thirteen-screen demo app, the runner with a persistent websocket, and the comparison scripts that produced the charts in this article (the main experiments are in runner/arms.py, together with a snapshot of the store after every run): <a href="https://github.com/chyiiiiiiiiiiii/flutter-jev">https://github.com/chyiiiiiiiiiiii/flutter-jev</a></p><p>My next step is to connect Jev to the story picking of my daily digest, because it is exactly the kind of task that finishes in one call: one news item in, three judgments out, no loop and no waiting for other systems. Based on the numbers above, the cost of that part can drop by two orders of magnitude. I will share the results when I have them.</p><h3>References</h3><ul><li><a href="https://github.com/chyiiiiiiiiiiii/flutter-jev">flutter-jev (code for this article)</a></li><li><a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">TypeSafe: Introducing System One models and Jev</a></li><li><a href="https://docs.typesafe.ai/confidence">Jev confidence docs</a></li><li><a href="https://console.typesafe.ai">TypeSafe Console (get an API key)</a></li><li><a href="https://openrouter.ai/docs/guides/community/jev">OpenRouter: Jev</a></li><li><a href="https://github.com/leancodepl/marionette_mcp">marionette_mcp</a></li><li><a href="https://x.com/AbdallahSh07/status/2101719364450046351">Abdallah Shaban’s GenUI demo</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ee519ee0571f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/your-widget-tree-is-already-a-list-of-options-where-jev-fits-in-a-flutter-project-ee519ee0571f">Your widget tree is already a list of options: where Jev fits in a Flutter project</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Flutter Gemma: The hidden gem of cross-platform local AI]]></title>
            <link>https://medium.com/flutter-community/flutter-gemma-the-hidden-gem-of-cross-platform-local-ai-cffdaaf007de?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/cffdaaf007de</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[flutter-app-development]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Mobile@Exxeta]]></dc:creator>
            <pubDate>Fri, 25 Sep 2026 19:59:46 GMT</pubDate>
            <atom:updated>2026-09-25T19:59:45.628Z</atom:updated>
            <content:encoded><![CDATA[<h4>Build Flutter mobile apps with cutting-edge local LLM features</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cS8ax8G3kk4UkmMZVKKZtQ.jpeg" /><figcaption>Photo by <a href="https://www.pexels.com/photo/a-white-and-green-machine-with-people-on-it-18069490/https://www.pexels.com/photo/a-white-and-green-machine-with-people-on-it-18069490/">Google DeepMind</a> from Pexels</figcaption></figure><p>One codebase, multiple platforms, and all using local LLMs — can this work without compromises? When thinking about integrating local LLM features into your app, you will also start thinking about performance soon. The answer to performance is often “build native”. It makes perfect sense considering with LLMs you’ll want GPU access and NPU acceleration. All that is platform-specific.<br>And yet, <a href="https://pub.dev/packages/flutter_gemma">Flutter Gemma</a> v1.x takes a modular approach to resolve this conflict. In this article we’ll show you exactly how this works using a real demo app for Android and iOS with six feature areas: setup, text generation, analysis, embeddings, RAG capabilities, and chat.</p><h3>Why Cross-platform for on-device AI?</h3><p>For classical business use cases, the argument for having one codebase is well-known: Write logic once and deploy on multiple platforms like iOS and Android. For features including local models, this becomes more important because model handling and prompting strategies come on top of classical UI frontend development. Things you really don’t want to maintain in more than one place.</p><p>The inference itself stays platform-specific. Native runtimes, GPU, and NPU access differ fundamentally between Android and iOS. Android relies on open acceleration APIs such as <a href="https://developer.android.com/ndk/guides/neuralnetworks">NNAPI</a> and GPU delegates via <a href="https://github.com/google-ai-edge/litert">LiteRT</a>, used to run <a href="https://developer.android.com/ai/gemini-nano">Gemini Nano</a> on modern Android devices. Apple devices use Core ML and the Neural Engine to run the <a href="https://developer.apple.com/documentation/foundationmodels">Foundation Model </a>inside a closed, vendor-specific stack. A good cross-platform layer bridges this gap without hiding it. The developer is still in control of which engine runs on which platform. For real-time applications where performance is critical, native development remains the way to go. However, for most product features related to on-device local LLMs, Flutter is becoming an increasingly viable choice.</p><h3>The Ecosystem: Cross-platform options for local LLMs</h3><p>Before diving deep into <em>Flutter Gemma</em>, we will look at the solution space: Generic approaches rely on <a href="https://pub.dev/packages/ffi">Dart FFI</a> and bind directly to <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a>/<a href="https://github.com/ggml-org/ggml">ggml</a> or custom platform channel bridges for each platform. For example, <a href="https://pub.dev/packages/llamadart">llamadart</a> uses llama.cpp for <a href="https://huggingface.co/docs/hub/gguf">GGUF</a> models and LiteRT for .litertlm models. But there are also pure Dart approaches even without an ffi dependency like <a href="https://pub.dev/packages/ollama_dart">ollama_dart</a>. In addition, there are other smaller Flutter packages with similar concepts at various stages of maturity and varying levels of maintenance and active development.</p><p>Outside of Flutter, it’s worth taking a look at the purely native solution <a href="https://github.com/eastriverlee/LLM.swift">LLM.swift</a> for <em>iOS</em>. It is a platform-exclusive approach that clearly illustrates the native control you give up when you opt for cross-platform development but also how much platform-specific work you save yourself in the process.</p><p>The common thread among most approaches is that they solve the question “can the model run at all?” rather than “how do I build a product feature interface around it?” That’s exactly where Flutter Gemma comes in.</p><h3>Why Flutter Gemma stands out</h3><p>Since release v1.0.0 Flutter Gemma follows a modular architecture: a core package and optional engine packages, instead of one big monolithic dependency. Right now, developers can choose between the following:</p><ul><li>flutter_gemma_mediapipe: .task and .bin models (<em>Android</em>, <em>iOS</em>, Web)</li><li>flutter_gemma_litertlm: .litertlm models (<em>Android</em>, <em>iOS</em>, Desktop)</li><li>flutter_gemma_embeddings: text embeddings</li><li>flutter_gemma_rag_sqlite: sqlite vector store enabling RAG capabilities</li></ul><h3>How to choose the correct model for Flutter Gemma?</h3><p>Answering this question isn’t too easy and includes more pitfalls than you might think at first.</p><h4>Format first</h4><p><em>Flutter Gemma</em> does not just load any <em>GGUF</em> or <em>Safetensor</em> file you give it to. It explicitly expects .task/ .bin files (<em>MediaPipe</em>) or .litertlm files (<em>LiteRT-LM</em>). Both formats are already converted and ready for the engine. Practically this means: You look for a fitting model on HuggingFace, e.g., the Gemma family from the <a href="https://huggingface.co/google">Google community</a> or some <em>LiteRT-LM</em> models from the <a href="https://huggingface.co/litert-community">LiteRT community</a>, instead of converting a model yourself.</p><h4>Quantization is no trivial matter</h4><p>Most pre-converted models are quantized, very often as <em>INT4</em> (sometimes as <em>INT8</em>). This drastically reduces download size and RAM requirements — exactly what makes on-device inference on a smartphone even possible in the first place. The trade-off is a certain loss of quality compared to full precision. For most everyday use cases, like rewriting, classification, and summarization, <em>INT4</em> is a good compromise. If you think the output is becoming too imprecise, you should look for a higher-resolution variant of the same model, rather than simply choosing a larger model across the board.</p><p>RAM is the real limiting factor, not storage space: The download size provides only a rough estimate for the required RAM. During inference, the model requires additional RAM depending on the context length and backend (<em>LiteRT-LM</em> or <em>MediaPipe</em>), which can result in significantly larger memory usage than the file size. A model that runs smoothly on your own test device may already reach its limits on an older device or one with less RAM and crash the app when trying to load the model or run inference.</p><h4>Additional Considerations for Selection</h4><ul><li><strong>Gated vs public</strong> <br>Some models like <a href="https://huggingface.co/collections/google/gemma-3-release">Gemma3</a>, <a href="https://huggingface.co/collections/google/gemma-4">Gemma4</a> or <a href="https://huggingface.co/collections/google/embeddinggemma">EmbeddingGemma</a> require a license agreement and a <a href="https://huggingface.co/docs/hub/security-tokens">HuggingFace API token</a>, while <a href="https://huggingface.co/Qwen/Qwen3-0.6B">Qwen3</a> or <a href="https://huggingface.co/litert-community/Gecko-110m-en">Gecko</a> are freely available. This is relevant for user onboarding as well as CI/CD and not just for local development. We enjoy using Gemma models because they offer pretty good quality in the context of mobile on-device inference, but <em>Qwen3</em> and <em>Gecko</em> are great alternatives that don’t require auth.</li><li><strong>Backend support<br></strong>Not every quantization level performs equally on GPU and CPU delegates. Some combinations only perform well on one of the two. This requires good testing with a variety of devices.</li><li><strong>Context length</strong> <br>Smaller models often come with smaller maximum context windows. For long RAG contexts or detailed chat histories, this can quickly become a limiting factor. In our experience, this is the biggest drawback — but for all mobile local LLMs.</li></ul><h3>The Flutter Gemma Demo App</h3><p>For this article, we built a small Flutter app that serves as a central theme. It uses flutter_gemma and all its submodules for model setup and inference as well as flutter_bloc for state management. We’re deliberately leaving out the navigation and similar peripheral topics. The app is divided into five tabs (Generation, Analysis, Embedding, RAG and Chat) and a setup screen. Each feature is built as its own Bloc + Widget pair with model access handled centrally using the FlutterGemma API.</p><p>The demo app uses <a href="https://huggingface.co/litert-community/Qwen3-0.6B">Qwen3 0.6B</a> for text inference and <a href="https://huggingface.co/litert-community/Gecko-110m-en">Gecko 256</a> for embeddings. Both models are publicly available and do not require a <em>HuggingFace</em> API token.</p><p>The central initialization runs once at app start and includes registering the engine.</p><pre>Future&lt;void&gt; main() async {<br>  // ...<br>  await FlutterGemma.initialize(<br>    inferenceEngines: const [<br>      LiteRtLmEngine(), // .litertlm — Android, iOS, Desktop<br>      MediaPipeEngine(), // .task / .bin — Android, iOS, Web<br>    ],<br>    embeddingBackends: const [<br>      LiteRtEmbeddingBackend(),<br>    ],<br>    vectorStore: kIsWeb ? WebSqliteVectorStore() : SqliteVectorStore(),<br>    huggingFaceToken: dotenv.env[&#39;HUGGING_FACE_API_KEY&#39;],<br>  );<br>}</pre><h3>Model Setup: Download, Installation, Availability</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ohf3PH0CZ16Itrsjpg8hYw.png" /></figure><p>The entry point for the app is the Setup screen. First, it checks if there is any model installed without loading it.</p><pre>/// [hasActiveModel] is a synchronous and cheap check<br>if (FlutterGemma.hasActiveModel()) { <br>  emit(state.copyWith(status: SetupStatus.ready));<br>} else {<br>  emit(state.copyWith(status: SetupStatus.notInstalled));<br>}</pre><p>If there is no model available, the app automatically initiates a download from <em>HuggingFace</em> with a progress bar visible in the UI.</p><pre>await FlutterGemma.installModel(<br>      modelType: ModelType.qwen3,<br>      fileType: ModelFileType.litertlm,<br>    )<br>    .fromNetwork(<br>      ModelConstants.inferenceModelUrl,<br>    )<br>    .withProgress((progress) =&gt; add(_ProgressUpdated(progress)))<br>    .install();</pre><p>Public models like <em>Qwen3</em> or <em>Gecko</em> do not need API tokens. If your model requires a token, you can use a .env file in combination with the <a href="https://pub.dev/packages/dotenv">dotenv</a> package to securely provide the API token. If the download fails, e.g., a network or storage problem occurs, the package throws a dedicated DownloadException instead of a generic Exception or Error:</p><pre>try {<br>  await FlutterGemma.installModel(<br>    modelType: ModelType.qwen3,<br>    fileType: ModelFileType.litertlm,<br>  )<br>  .fromNetwork(<br>    ModelConstants.inferenceModelUrl,<br>    // dotenv.env[&#39;HUGGING_FACE_API_KEY&#39;], // pass token here if required<br>  )<br>  .withProgress((progress) =&gt; add(_ProgressUpdated(progress)))<br>  .install();<br>} on DownloadException catch (e) {<br>  emit(<br>    state.copyWith(<br>      status: SetupStatus.error,<br>      errorMessage: e.error.toUserMessage(),<br>    ),<br>  );<br>}</pre><p>In short: Setup is the only place where network or storage problems could realistically occur. The rest of the app can rely on the already installed models.</p><h3>Feature: Text generation (Rewrite &amp; Summarize)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lfZhbUYp4ywcoWPFKcC0Ug.png" /></figure><p>The <strong>Generation</strong> Tab rewrites text in a specific mood or simply summarizes the text. The prompt gives the model clear instructions, including the instruction to preserve critical data like numbers or names.</p><pre>enum AiMood {<br>  professional,<br>  casual,<br>  concise,<br>  analytical;<br><br>  String get label {<br>    return switch (this) {<br>      AiMood.professional =&gt; &#39;Professional&#39;,<br>      AiMood.casual =&gt; &#39;Casual&#39;,<br>      AiMood.concise =&gt; &#39;Concise&#39;,<br>      AiMood.analytical =&gt; &#39;Analytical&#39;,<br>    };<br>  }<br>}<br><br>final prompt = &#39;&#39;&#39;<br>You are a professional writing assistant. Rewrite the following text <br>to sound highly ${event.mood.label.toLowerCase()}. Maintain all <br>critical data facts, numbers, dates, names, and contact details <br>exactly as written. Do not omit them.\n\n<br>Text to rewrite:\n${state.inputText};&#39;&#39;&#39;</pre><p>The technical flow behind this is a pattern, that will be used in several places in the app:</p><ol><li>Get the active model</li><li>Create a chat session</li><li>Get the response</li><li>Close the model</li></ol><pre>final model = await FlutterGemma.getActiveModel(<br>  maxTokens: ModelConstants.maxTokens,<br>);<br>try {<br>  final chat = await model.createChat();<br>  await chat.addQueryChunk(Message.text(text: prompt, isUser: true));<br>  final response = await chat.generateChatResponse();<br>  final text = response is TextResponse ? response.token : &#39;&#39;;<br>  emit(state.copyWith(status: GenerationStatus.success, output: text));<br>} finally {<br>  await model.close();<br>}</pre><p>This chat/session pattern of Flutter Gemma is perfectly fine and a good reusable pattern for every text feature, but not for classical chats, as we will see later in the chapter <strong><em>Feature: Chat with History</em></strong>. The quality of responses varies a lot between models used. For <em>Qwen3 0.6B</em>, the results for summarization and rewriting are quite good, although sometimes it just repeats the original text.</p><h3>Feature: Structured Analysis (Classification and Entity Extraction)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HwL5ShbgYjXqFYZ6o9muCg.png" /></figure><p>The <em>Analysis</em> tab includes two scenarios: Classifying a support mail and extracting entities from log entries. The key to getting structured output here is prompting and telling the model (<em>Qwen3</em>) to generate plain JSON without Markdown or explanations.</p><pre>const prompt = &#39;&#39;&#39;<br>Classify this content and respond with valid JSON only — <br>no markdown, no explanation:\n<br>$_kScenario3Text\n\n<br>JSON format:\n<br>{&quot;category&quot;:&quot;...&quot;,&quot;priority&quot;:&quot;...&quot;,&quot;labels&quot;:[&quot;...&quot;,&quot;...&quot;]};&#39;&#39;&#39;</pre><p>Even the bigger mobile local LLMs don’t always strictly follow these rules. That’s why post-processing matters even more. The code must be robust against format deviations. An easy fallback is returning the raw response.</p><pre>String _formatClassification(String raw) {<br>  try {<br>    final json = jsonDecode(_extractJson(raw)) as Map&lt;String, dynamic&gt;;<br>    final category = json[&#39;category&#39;] ?? &#39;—&#39;;<br>    final priority = json[&#39;priority&#39;] ?? &#39;—&#39;;<br>    final labels = (json[&#39;labels&#39;] as List?)?.join(&#39;, &#39;) ?? &#39;—&#39;;<br>    return &#39;📋 CLASSIFICATION REPORT\n&#39;<br>        &#39;Category: $category\n&#39;<br>        &#39;Priority: $priority\n&#39;<br>        &#39;Tags: $labels&#39;;<br>  } catch (_) {<br>    return raw.trim();<br>  }<br>}<br><br>String _extractJson(String raw) {<br>  final start = raw.indexOf(&#39;{&#39;);<br>  final end = raw.lastIndexOf(&#39;}&#39;);<br>  if (start == -1 || end == -1) return raw;<br>  return raw.substring(start, end + 1);<br>}</pre><p>While classification with <em>Qwen3 0.6B</em> seems to work reliably, entity extraction almost never succeeds in extracting all the information. In most cases it struggles to detect the “Impacted Users” (which is “14,000” in the example, but “0” got extracted). Repeating the entity extraction multiple times results in different outcomes for each execution.</p><h3>Feature: Embeddings and Semantic Similarity</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DVS6tP4cHdMuqo3Dh_fp3g.png" /></figure><p>The Embedding tab uses its own model (<em>Gecko 256</em>), along with its own runtime ( flutter_gemma_embeddings), separate from the text generation model (<em>Qwen3</em>). Embeddings are a different class of models with their own workflow and use case: similarity and search rather than text generation. They need a completely different mental model consisting of vectors instead of words and similarity instead of responses. When choosing a model, there’s a classic trade-off. For example, <em>Gecko 256</em> is small and publicly available, while <em>EmbeddingGemma 512</em> is more accurate but gated.</p><p>The technical flow is rather simple:</p><ol><li>Embed reference text</li><li>Embed user input</li><li>Calculate cosine similarity between the two vectors</li></ol><pre>final embedder = await FlutterGemma.getActiveEmbedder();<br>try {<br>  final inputVec = await embedder.generateEmbedding(<br>    state.similarityInput,<br>  );<br>  final score = _cosineSimilarity(state.referenceVector!, inputVec);<br>  emit(<br>    state.copyWith(<br>      status: EmbeddingStatus.referenceReady,<br>      similarityScore: score,<br>    ),<br>  );<br>} finally {<br>  await embedder.close();<br>}</pre><p>Calculating the cosine similarity is pure Dart code and not part of the FlutterGemma API.</p><pre>double _cosineSimilarity(List&lt;double&gt; a, List&lt;double&gt; b) {<br>  double dot = 0, magA = 0, magB = 0;<br>  for (var i = 0; i &lt; a.length; i++) {<br>    dot += a[i] * b[i];<br>    magA += a[i] * a[i];<br>    magB += b[i] * b[i];<br>  }<br>  final denom = math.sqrt(magA) * math.sqrt(magB);<br>  return denom == 0 ? 0 : dot / denom;<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fGM1s-EjxXJAX9iCZos8Yw.png" /></figure><p>In the demo app, we embedded a small text about planets. Comparing our two inputs, “Planets are big rocks in the universe” <strong>(A)</strong> and “Bees are very important insects because they are the global pollinators” <strong>(B)</strong>, we can see that input A has a higher similarity score than input B because it is semantically closer to the topic in the embedded text. At least with English text, <em>Gecko 256</em> enables reliable similarity matching on mobile devices.</p><h3>Feature: RAG with SQLite-VEC and filters</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kSSWLduD_Z4bDYhae6vskQ.png" /></figure><p>The RAG tab comes with its own small knowledge base consisting of seven short documents on science, cooking, and history. The knowledge base is indexed and searchable fully on-device, without a server or an external vector store.</p><p>Notably, we don’t need to install a second embedding model to use <em>Flutter Gemma</em> RAG capabilities. Instead, we use the same active <em>Gecko 256</em> embedder as the <em>Embedding</em> tab.</p><p>Storage is handled by flutter_gemma_rag_sqlite. A <a href="https://github.com/asg017/sqlite-vec">SQLite-VEC</a> store is created directly in the app’s <em>Documents</em> directory, and indexing is performed via addDocument() using text and <em>JSON</em> metadata.</p><pre>await FlutterGemma.rag.initialize(&#39;${dir.path}/$_kRagDbFileName&#39;);<br><br>for (final doc in _kCorpus) {<br>  await FlutterGemma.rag.addDocument(<br>    id: doc.id,<br>    content: doc.content,<br>    metadata: doc.metadataJson, // {&quot;topic&quot;: &quot;...&quot;, &quot;year&quot;: ...}<br>  );<br>}</pre><p>The real “out-of-the-box” experience is delivered by searchSimilar(). Embedding the user request and performing a cosine-similarity search run in one single call — no manual vector handling required like in the <em>Embedding</em> chapter.</p><pre>final results = await FlutterGemma.rag.searchSimilar(<br>  query: state.searchQuery,<br>  topK: 5,<br>  filter: _buildFilter(),<br>);</pre><p>New in <em>Flutter Gemma v1.x</em> are type-safe metadata filters FilterSchema and FilterField, declared once in FlutterGemma.initialize().</p><pre>await FlutterGemma.initialize(<br>    inferenceEngines: const [<br>      LiteRtLmEngine(),<br>      MediaPipeEngine(),<br>    ],<br>    embeddingBackends: const [<br>      LiteRtEmbeddingBackend(),<br>    ],<br>    vectorStore: kIsWeb ? WebSqliteVectorStore() : SqliteVectorStore(),<br>    filterSchema: const FilterSchema(<br>      fields: [<br>        FilterField(name: &#39;topic&#39;, type: FilterFieldType.string),<br>        FilterField(name: &#39;year&#39;, type: FilterFieldType.number),<br>      ],<br>    ),<br>    huggingFaceToken: dotenv.env[&#39;HUGGING_FACE_API_KEY&#39;],<br>  );</pre><p>As a result, the filters for topic and year run as true vec0 column conditions directly in sqlite-vec, rather than post-filtering in Dart. Every column in a virtual table follows strict rules for typing, formatting, and structure requirements. This means robustness, better performance, and less development overhead. In the demo, topic chips and a “from 2020” filter combine to form exactly such a typed condition.</p><pre>Filter? _buildFilter() {<br>  final must = &lt;Condition&gt;[];<br>  if (state.topicFilter != kAllTopics) {<br>    must.add(FieldEquals(key: &#39;topic&#39;, value: state.topicFilter));<br>  }<br>  if (state.recentOnly) {<br>    must.add(FieldRange(key: &#39;year&#39;, gte: 2020));<br>  }<br>  return must.isEmpty ? null : Filter(must: must);<br>}</pre><p>Each match also provides a similarity score, which makes it easy to distinguish strong matches from weak ones. While the Embedding chapter shows the basic concept of similarity between two texts, the RAG tab shows a practical application as a searchable knowledge base.</p><p>In short, with sqlite-vec and typed filters, <em>Flutter Gemma</em> delivers RAG as a ready-made component rather than a patchwork of individual parts (or packages in <em>Flutter</em> terms).</p><p>To get a good mixture of knowledge in the demo app, we provided documents from different centuries about science, history, and cooking with rather complex information to really challenge the on-device LLM capabilities. Again, the <em>Gecko 256 Embedder</em> does a great job, and RAG features of Flutter Gemma seem to work like a charm. Asking, “How is sourdough bread made?</p><h3>Feature: Chat with History</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D8vn5pP89VQSpLPs4SCnRA.png" /></figure><p>The Chat tab differs from the previous features in a crucial way: the Generation and Analysis tabs create a new chat session for each request, fetch a single response, and close the model again. For a real chat, this is insufficient because the model needs to remember the whole conversation. In Flutter Gemma this is not a special case but a matter of lifecycle management: InferenceChat maintains its own history internally if you reuse the same instance across multiple turns, rather than creating a new one for each message.</p><pre>Future&lt;void&gt; _onStarted(ChatStarted event, Emitter&lt;ChatState&gt; emit) async {<br>  final model = await FlutterGemma.getActiveModel(<br>    maxTokens: ModelConstants.maxTokens,<br>  );<br>  final chat = await model.createChat(<br>    modelType: ModelType.qwen3, <br>  );<br>}</pre><p>The parameter modelType is not a minor detail here. For <em>Qwen3</em> <em>Flutter Gemma</em> automatically appends /no_think to every user message to suppress the built-in thinking mode of the model. The &lt;think&gt; tags are also filtered from the response stream. Without this parameter, <em>Qwen3</em> behaves significantly more unpredictably.</p><p>Every new message is passed to the chat instance via addQueryChunk(). The method generateChatResponseAsync() streams the response token by token. An emit.forEach loop is used to transfer those tokens into UI state.</p><pre>Future&lt;void&gt; _onSubmitted(ChatMessageSubmitted event, Emitter&lt;ChatState&gt; emit,) async {<br>  await chat.addQueryChunk(Message.text(text: text, isUser: true));<br><br>  final buffer = StringBuffer();<br>  await emit.forEach&lt;ModelResponse&gt;(<br>    chat.generateChatResponseAsync(),<br>    onData: (response) {<br>      if (response is TextResponse) buffer.write(response.token);<br>      return state.copyWith(messages: _withStreamedReply(buffer.toString()));<br>    },<br>  );<br>}</pre><p>History is being persisted in session memory as well as internally by InferenceChat from <em>Flutter Gemma</em>. Like this, the next call of generateChatResponseAsync() knows the whole conversation and not only the last message. This means we don’t have to glue something together or maintain the history ourselves.</p><p>Resetting a conversation with clearHistory() does not only clear the message list but also closes the native session and reopens it. Otherwise, the model would continue to respond based on a transcript that is no longer visible in the UI.</p><pre>await chat.clearHistory();</pre><p>During this chapter, we learned that multi-turn chat is not an extra feature of Flutter Gemma but rather a direct result of the InferenceChat lifecycle. Chat history is being persisted by keeping the chat instance alive and not because you rebuild it yourself.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bN7052q2OcsnsuHUQbPaNA.png" /></figure><p><em>Qwen3 0.6B</em> has met exactly the expectations we had for an LLM running on smartphone hardware. The quality of the chat is “okay”, but not really much more than that. <em>Qwen3 0.6B</em> has a mediocre understanding of context: It can stick to a topic quite well, but most information is hallucinated entirely. Sometimes the model strays from the topic after one or two requests. In one case, the model responded with an empty text after three questions, and it did not respond to requests anymore. After clearing the chat, it worked again. Usually, we can write about 8–15 messages before the end of the context window is reached. What I’ve noted very positively is that mixing languages like English and German seems to work better than expected. The model can translate a sentence and correctly switch back to the original language of the conversation. The only problem that occurs when using a language other than English: there are garbled characters at the end of the messages. I don’t know why but it always happens as soon as I start chatting in German. Of course, you must take the model size into account here, and for just 0.6B parameters, it’s astonishing that it produces such a quality at all. Newer models like <em>Gemma4</em>, with more parameters and better quantization, are likely to perform even significantly better in this regard. However, such a comparison would go beyond the scope of this article.</p><h3>Limitations, Pitfalls and Practical Lessons</h3><p>On-device models have real storage and memory budgets. Download size and RAM requirements during inference on mobile devices are not an implementation detail — they are a hard limiting factor. Recurring patterns in the demo app: Models need to be closed (e.g., model.close() and embedder.close()) after finishing the task, or otherwise the resources will not be freed.</p><p>Despite a unified Dart API, platform differences persist under the hood. The engines of <em>MediaPipe</em> and <em>LiteRT</em>, as well as the systems <em>iOS</em> and <em>Android</em> themselves, do not always behave identically. There are cases where native approaches remain the better choice: Performance-critical scenarios or highly platform-specific hardware acceleration (as with <em>LLM.swift</em> mentioned earlier).</p><h3>Conclusion</h3><p>Cross-platform and local AI are not mutually exclusive. This article’s demo app demonstrates how the modular approach of Flutter Gemma resolves an apparent conflict of objectives. For teams that maintain a shared codebase for Android and iOS and don’t have extremely performance-critical real-time requirements, this is a very practical path to on-device LLM features.</p><p>Flutter Gemma really shines for small tasks and is a great way to implement local AI features for older smartphones that don’t have native support for Google’s Gemini Nano or Apple’s Foundation Model. Where native solutions continue to lead remains a matter of requirements.</p><p>This is evident across all five feature areas: Flutter Gemma doesn’t just answer the question, “Does the model even run?”. Instead, it provides the concepts needed for real product features, ranging from the download pipeline and typed RAG filters to the session lifecycle in chat. The actual work then lies in prompt engineering and product logic, not in the infrastructure.</p><p>The quality of the output from on-device smartphone LLMs remains a matter of debate. In our view, it’s already suitable for some use cases, but not so much for others. For example, summarizing and classifying shorter texts, as well as embedding texts, works surprisingly well with these small models. However, when working with complex, longer texts or performing entity extraction, there are still too many errors, so these tasks should continue to be handled by a powerful external model.</p><p>You can find the complete code in the our GitHub repository:</p><p><a href="https://github.com/EXXETA/flutter_local_llm">GitHub - EXXETA/flutter_local_llm: A small cross-platform mobile app to demonstrate on-device LLMs with Flutter Gemma. It features text generation, classification, entity extraction, RAG capabilities and a chat.</a></p><p>Feel free to try out the app right away with your own model! <br>(by Jonas Klock)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cffdaaf007de" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/flutter-gemma-the-hidden-gem-of-cross-platform-local-ai-cffdaaf007de">Flutter Gemma: The hidden gem of cross-platform local AI</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your Adaptive UI Has No Screenshot]]></title>
            <link>https://medium.com/flutter-community/your-adaptive-ui-has-no-screenshot-1babc65c38f7?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/1babc65c38f7</guid>
            <category><![CDATA[testing]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Rudraksh Shukla]]></dc:creator>
            <pubDate>Mon, 21 Sep 2026 15:46:17 GMT</pubDate>
            <atom:updated>2026-09-21T15:46:16.180Z</atom:updated>
            <content:encoded><![CDATA[<p>Part 2 of The Death of the Static UI — how to test, review and ship an interface that renders differently for every user</p><blockquote><strong><em>Everything here runs.</em></strong><em> Clone it, run the tests, drag the sliders: </em><a href="https://github.com/rudrakshshukla/context_aware_transit"><em>github.com/rudrakshshukla/context_aware_transit</em></a></blockquote><p>A bug report lands on a Tuesday. This is all it says:</p><blockquote><em>“The Buy ticket button was gone. Pixel 8, around 8am. No screenshot, sorry.”</em></blockquote><p>You open the app. The button is there. QA opens the app. The button is there. You check the crash logs, the feature flags, the release diff. Nothing.</p><p>The ticket sits in triage for eleven days. Then someone closes it: <em>cannot reproduce</em>.</p><p>But it was reproducible. Just not at your desk, at 3pm, sitting still, on office Wi-Fi. And that is the only context your tests have ever run in.</p><p>This is what <a href="https://medium.com/flutter-community/the-death-of-the-static-ui-building-context-aware-mobile-apps-in-2026-ddd06d25a473">Part 1</a> costs you. Once your UI is built from live context instead of a fixed layout, “does the screen look right?” has no answer. There is no screen. There are thousands of them, one for every combination of context, and your tests have seen about four.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*fFAm_wbV_22qhGPg.png" /></figure><p>The good news: other fields solved this long ago. Mobile just hasn’t caught up.</p><h3>Three ways an adaptive UI breaks</h3><p>Most teams treat these as one problem. They are three, and each needs a different test.</p><p><strong>1. Wrong screen.</strong> The app read the context correctly, then picked the wrong layout. Someone running for a train gets a browse grid. This is a logic bug, and logic is cheap to test.</p><p><strong>2. Flickering screen.</strong> The app picked correctly twice, three seconds apart, and the layout changed under the user’s thumb. Every single frame is correct. The bug only exists over time. This is almost certainly our Tuesday ticket.</p><p><strong>3. Screen nobody sees.</strong> A layout that is correct, stable, and never happens. You build a feature that only appears in bright sunlight, while driving, above 90% battery. That is 0.3% of sessions. The feature is dead and nobody notices.</p><p>Widget tests catch the first one badly. They cannot see the other two at all.</p><h3>First, make context a plain value</h3><p>Everything else depends on this one move.</p><p>In Part 1, the context engine gave us a stream. Streams are fine in production and painful in tests, because you have to drive them. What you want instead is a plain value you can type out in one line.</p><pre>class UiContext {<br>  const UiContext({<br>    required this.motion,<br>    required this.daypart,<br>    required this.lux,<br>    required this.batteryPct,<br>    required this.online,<br>    required this.quickActionConfidence,<br>  });<br>  // ... plus copyWith<br>}</pre><p>Six fields. That is the whole input to your UI.</p><p>Add <em>copyWith</em>, and do not skip it. You will write hundreds of test contexts, and almost all of them are “the normal one, but walking” or “the normal one, but offline”. Without copyWith your test files get unreadable, and once they are unreadable people stop adding cases.</p><p>Now separate the decision from the drawing:</p><pre>LayoutSpec decideLayout(UiContext c) {<br>  // Rule 0 — offline. Never show something that needs the network.<br>  if (!c.online) return const Discovery([kCachedTickets, kOfflineMap]);</pre><pre>  // Rule 1 — nearly dead battery. Show one thing only.<br>  if (c.batteryPct &lt; kLowBatteryThreshold) {<br>    return const QuickAction(kBuyTicket, reason: &#39;low_battery&#39;);<br>  }</pre><pre>  // Rule 2 — the commuter from Part 1. Moving, and the model is sure.<br>  if (c.quickActionConfidence &gt;= kQuickActionThreshold &amp;&amp;<br>      c.motion != Motion.still) {<br>    return const QuickAction(kBuyTicket, reason: &#39;commuter_intent&#39;);<br>  }</pre><pre>  // Rule 3 — otherwise, let them browse.<br>  return const Discovery([kRoutes, kPasses, kOffers]);<br>}</pre><p>No widgets. No async. No plugins. No clock. This function runs about a hundred thousand times a second.</p><p>And there is not one <em>if</em> statement in home_screen.dart. All the branching lives here, where a test reaches it instantly.</p><p>One small thing that matters more than it looks: give LayoutSpec real value equality, and leave <em>reason</em> out of it. The reason is for your logs, not for the user. If two different rules both say “show the buy button”, the screen did not change. The stability test later counts changes the user can see, so including reason would make it fail on refactors nobody could notice. And if you skip equality altogether, every “did the layout change?” check returns false, every stability test passes without testing anything, and you ship the flicker bug with a green board.</p><h3>Test the decision, not the pixels</h3><pre>final cases = &lt;(String, UiContext, Matcher)&gt;[<br>  (&#39;sitting at home, just browsing&#39;,<br>      base, isA&lt;Discovery&gt;()),<br>  (&#39;walking, model is confident — show one action&#39;,<br>      base.copyWith(motion: Motion.walking, quickActionConfidence: 0.9),<br>      isA&lt;QuickAction&gt;()),<br>  (&#39;walking, model is unsure — let them browse&#39;,<br>      base.copyWith(motion: Motion.walking, quickActionConfidence: 0.5),<br>      isA&lt;Discovery&gt;()),<br>  (&#39;very confident but standing still — still browse&#39;,<br>      base.copyWith(quickActionConfidence: 0.99), isA&lt;Discovery&gt;()),<br>  (&#39;offline — never offer a purchase&#39;,<br>      base.copyWith(online: false, quickActionConfidence: 0.99),<br>      isA&lt;Discovery&gt;()),<br>  (&#39;battery about to die — one action only&#39;,<br>      base.copyWith(batteryPct: 4), isA&lt;QuickAction&gt;()),<br>];</pre><pre>for (final (name, ctx, matcher) in cases) {<br>  test(name, () =&gt; expect(decideLayout(ctx), matcher, reason: &#39;$ctx&#39;));<br>}</pre><p>This file is your product spec. Not a document somewhere. This file.</p><p>When a product manager says “push people to buy when they’re clearly in a rush”, that sentence becomes a row here before anyone touches a widget. Read the file top to bottom and a non-engineer should follow it.</p><p>Look at the fourth row. Very confident, but standing still. The model is <em>more</em> sure than in row two, and the app deliberately does <em>less</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*8ZWXTuWXJUYE1cUs.png" /></figure><p>I did not think of that case when I wrote the rule. I found it ten seconds after dragging a slider in the Context Lab, which I will come back to.</p><h3>Screenshots: test the edges, not every combination</h3><p>Six context dimensions with a few useful values each gives you thousands of combinations. You cannot screenshot all of them, and you should not try. A golden file is only worth having if a human will actually look at it when it fails, and nobody reviews four thousand images.</p><p>So don’t cover the space. Cover its <strong>edges</strong>. For every rule, take the context just below its threshold and the context right at it. Then one normal example per branch.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*SY3GCEVGaF1dV0WL.png" /></figure><p>These ten are exactly the ones that break when someone changes a number:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*fZEIcMmRlKWB2hTd.png" /></figure><p>Two habits make this work.</p><p><strong>Name the file after the context, not the screen.</strong> “walk_confidence_0.72_at.png” tells a reviewer what broke. “home_screen_2.png” tells them nothing, and they will approve the diff without looking.</p><p><strong>Render through the same code production uses.</strong> If your test builds its own MaterialApp, your golden is testing that MaterialApp, not your app.</p><h3>Load real fonts, or your tests are green and blind</h3><p>This one cost me an afternoon, and it is the most fitting bug in the whole project.</p><p>When you run <em>flutter test</em>, Flutter uses a placeholder font. Every letter is drawn as a solid black rectangle.</p><p>My first ten screenshots had perfect layout, perfect spacing, and black bars where the words should be. <strong>Every test passed.</strong></p><p>Think about that. A screenshot suite can be green and blind at the same time. That is the exact shape of the bug this article opens with.</p><p>Fixing it takes two steps, and only one is obvious:</p><ul><li><strong>Icons are in the test bundle.</strong> Setting uses-material-design: true puts MaterialIcons there. Reading FontManifest.json loads them. This is what golden_toolkit does, and it works.</li><li><strong>Roboto is not.</strong> The default Material text style asks for a font that never ships inside your app. It lives in the Flutter SDK folder on your machine.</li></ul><p>So the usual fix gives you perfect icons and black-box text. That is worse than no fix, because it looks like it worked.</p><p>In the repo, loadAppFonts() handles both. It finds the SDK by walking up from the running Dart executable, so nothing gets committed and nothing gets downloaded, and it behaves the same on your laptop and in CI. If it cannot find the fonts, it fails the test instead of quietly saving black boxes. A silent pass is the enemy here.</p><h3>Properties: catching the cases you didn’t think of</h3><p>Edge screenshots cover the contexts you imagined. Adaptive UIs break on the ones you didn’t. So write down the things that must be true <em>everywhere</em>, then throw thousands of random contexts at them.</p><pre>test(&#39;invariants hold across 20000 random contexts&#39;, () {<br>  final r = Random(42);              // fixed seed: failures repeat<br>  for (var i = 0; i &lt; 20000; i++) {<br>    final c = randomContext(r);<br>    final spec = decideLayout(c);</pre><pre>    // 1. Same input, same output. Always.<br>    expect(decideLayout(c), spec, reason: &#39;not deterministic at $c&#39;);</pre><pre>    // 2. Never show the user an empty screen.<br>    expect(spec.surfacedFeatureIds, isNotEmpty, reason: &#39;empty at $c&#39;);</pre><pre>    // 3. Offline means no purchase path. No exceptions.<br>    if (!c.online) {<br>      expect(spec.surfacedFeatureIds, isNot(contains(kBuyTicket)));<br>    }<br>  }<br>});</pre><p>The first one looks pointless. It isn’t. The moment someone puts a DateTime.now() or a cached value inside the policy, this test goes red. And that is the worst bug you can have here: a UI you cannot reason about, because it is no longer a function of its inputs.</p><p>The third one is the one that pays off over years. Personalisation rules pile up over time. What eventually breaks is never the main path. It is some safety rule nobody realised everything depended on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3x-SXGRcNkqT06ub.png" /></figure><h3>Make the stability budget a real test</h3><p>Part 1 mentioned stability budgets as an idea. Turn it into a test that can fail.</p><p>The trick is a minimum waiting time. Once a layout is on screen, it stays for at least twenty seconds, whatever the model says:</p><pre>LayoutSpec apply(LayoutSpec candidate, DateTime now) {<br>  if (_current == null) { _current = candidate; _changedAt = now; return candidate; }<br>  if (candidate == _current) return _current!;<br>  if (now.difference(_changedAt!) &lt; dwell) { _suppressed++; return _current!; }<br>  _current = candidate; _changedAt = now;<br>  return candidate;<br>}</pre><p>Then test it over time, not in one frame. Replay sixty seconds of realistic sensor noise sitting right on the threshold:</p><pre>60s of sensor noise at the policy threshold<br>  without governor: 28 layout changes<br>  with governor:    2 layout changes (28 suppressed, budget 3)</pre><p>Twenty-eight rearrangements in one minute. That is what “the button was gone” feels like from the other side of the glass.</p><p>This is the only test in the suite that can see problem number two, because it is the only one that looks at time.</p><p>Two more things I learned writing it.</p><p><strong>Be honest about which assertions really test something.</strong> “At most 3 changes per minute” with a 20-second wait is guaranteed by the maths. No random seed can ever break it. Running that across 25 seeds looks rigorous and proves nothing. The assertion with teeth is the one next to it: two changes the user can see are never closer together than the waiting time, in any trace.</p><p><strong>Test the opposite failure too.</strong> Almost nobody does. If you tune the waiting time too high, your app stops adapting at all, and you are back to a static screen. There is a test for that.</p><h3>Reachability: the number nobody measures</h3><p>Code coverage tells you a widget was built. It does not tell you a real person ever saw it.</p><p>You can measure this privately, because a context is six numbers, not personal content. Collect them on device, send aggregates, and check what your users actually reach:</p><pre>reachability over 50000 contexts<br>  buy_ticket        16.09%<br>  routes            78.91%<br>  passes            78.91%<br>  offers            78.91%<br>  cached_tickets     5.00%<br>  offline_map        5.00%</pre><p>Put that table in any pull request that changes a threshold. It shows the real effect of a one-character diff.</p><p>It also answers a question product teams actually care about: what share of real sessions can reach this feature? That beats “is it on the home screen?”.</p><p>One more test in that file deliberately hides a feature deep in the rules, then checks that the detector catches it. A passing test is only worth trusting if you have proved it can fail.</p><h3>The parts that aren’t code</h3><p><strong>Designers review the rules, not the mockups.</strong> Give a designer one Figma frame and you get approval for one context. Give them the rule table plus the edge screenshots and you get approval for the whole system. They pick this up quickly. They have been doing responsive breakpoints for fifteen years, and this is the same idea with more inputs.</p><p>Better still, give them the real thing. Running the repo opens a <strong>Context Lab</strong>: a phone on one side, six sliders on the other. Drag the confidence slider past 0.72 and the interface turns into a different interface in your hand.</p><p>It is not a toy. It is where I found the “confident but standing still” case. Someone holding the Lab explores far more of the context space than someone reading a spec.</p><p><strong>Log the context with every event.</strong> If you record buy_ticket_tapped without the context that produced that layout, you have thrown away the thing that explains it. Log the confidence, the layout you chose, and whether the governor blocked a change. Those three fields turn “conversion dropped 4%, not sure why” into something you can debug.</p><h3>What actually changes</h3><p>None of this is new thinking. It is the move compilers made decades ago: stop checking the output, start checking the function that produces it, then prove some things are true for every possible input.</p><p>What is new is that mobile teams now need it too.</p><p>The fixed screen was never only a design choice. It was the thing that made testing possible. Give it up and you have to replace it with something: a pure decision function, a set of edge screenshots, a few invariants, a test that understands time, and a reachability number.</p><p>Do that, and “cannot reproduce” stops being a category of bug. The context that caused it is six numbers. Six numbers fit in a test.</p><p><strong>The code:</strong> <a href="https://github.com/rudrakshshukla/context_aware_transit">github.com/rudrakshshukla/context_aware_transit</a> — a runnable Flutter app and all 29 tests, with no dependencies beyond Flutter itself. On a fresh clone, flutter test works straight away.</p><p><strong>Part 1:</strong> <a href="https://medium.com/flutter-community/the-death-of-the-static-ui-building-context-aware-mobile-apps-in-2026-ddd06d25a473">The Death of the Static UI</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1babc65c38f7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/your-adaptive-ui-has-no-screenshot-1babc65c38f7">Your Adaptive UI Has No Screenshot</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Death of the Static UI: Building Context-Aware Mobile Apps in 2026]]></title>
            <link>https://medium.com/flutter-community/the-death-of-the-static-ui-building-context-aware-mobile-apps-in-2026-ddd06d25a473?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/ddd06d25a473</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[mobile-development]]></category>
            <category><![CDATA[agentic-ai]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Rudraksh Shukla]]></dc:creator>
            <pubDate>Mon, 14 Sep 2026 17:02:27 GMT</pubDate>
            <atom:updated>2026-09-20T08:52:00.564Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KPZkU_h_FgQEm33qqetQFg.png" /></figure><p>Every app you’ve ever shipped made the same quiet assumption: the interface is a fixed thing. You design a screen, you lay out the widgets, and every user sees the same arrangement in the same order — a 22-year-old on a commuter train at 8am and a 60-year-old at home on a Sunday get pixel-identical layouts. For thirty years that was simply what a UI <em>was</em>.</p><p>That assumption is dying. In 2026 the leading mobile apps treat the interface as a live surface that reshapes itself in real time — reordering navigation, resizing touch targets, shifting color and density, surfacing the one action you’re most likely to want next — driven by on-device signals about who you are, where you are, and what you’re doing right now. The static screen is becoming the exception, not the default.</p><p>Here’s what’s actually driving it, what it takes to build, and what it looks like in code — with Flutter examples throughout.</p><h3>From static layout to living surface</h3><p>The old personalization playbook was recommendation, not adaptation. Netflix reordered a content row; Spotify built you a playlist. The <em>chrome</em> around those recommendations — the navigation, the layout, the visual system — stayed frozen. Context-aware UX pushes personalization down into the interface itself.</p><p>Concretely, an adaptive UI reacts to signals like these:</p><ul><li><strong>Motion and activity</strong> — accelerometer and gyroscope tell you the user is walking, driving, or still. A UI can enlarge touch targets and simplify layout when it detects movement, cutting mis-taps.</li><li><strong>Location and environment</strong> — outdoors in bright light, boost contrast and switch to a high-legibility mode; on a known Wi-Fi network at home, load richer media.</li><li><strong>Time and calendar</strong> — automatic dark mode at night, a leaving-for-a-meeting layout when the next calendar event is 15 minutes out.</li><li><strong>Usage history</strong> — promote the three features this user actually touches, demote the ones they never open. A finance app foregrounds transfer for a power user and check balance for a casual one.</li><li><strong>Device and network state</strong> — drop to a minimalist, low-power layout on a dying battery or a weak connection.</li></ul><p>The through-line is <em>if/then UX logic</em>: the interface has conditional branches, and an on-device model decides which branch to render.</p><h3>What it looks like in the wild</h3><p>These aren’t hypotheticals. A few patterns already shipping:</p><p><strong>Spotify</strong> reshapes its home surface by daypart and activity — a running-friendly, low-interaction layout with big targets and workout playlists surfaces in the morning, while an evening browse session gets denser, discovery-heavy shelves. The context (time, motion, listening history) reorders the <em>screen</em>, not just the recommendations inside it.</p><p><strong>Starbucks</strong> drives its app home screen with a real-time decision engine that factors in time of day, weather, store location, and past orders — a cold-brew card on a hot afternoon near a store you frequent, a reorder-your-usual button when you’re on your commute route. The same app renders materially different first screens to different users in different moments.</p><p><strong>Banking apps</strong> increasingly reorder the dashboard by learned behavior: a user who checks balances and nothing else gets balance-first, while someone who transfers money weekly gets transfer promoted to the top action. Same binary, two different information architectures.</p><p><strong>Fitness apps</strong> modify the dashboard layout based on workout frequency and current activity state — surfacing start-workout and simplifying the interface when the accelerometer says you’re moving, then switching to progress charts and history when you’re stationary.</p><p>The unifying idea: the interface is a function of context, computed fresh, on the device.</p><h3>Why Flutter is a natural fit</h3><p>Flutter’s core model is UI = f(state) — widgets rebuild whenever the state they depend on changes. Context-aware UX is just that idea with the state coming from <em>sensors and a model</em> instead of only user taps. You don&#39;t need a new paradigm; you point your existing reactive plumbing (StreamBuilder, ValueListenableBuilder, Riverpod, Bloc — pick your poison) at a stream of context and let the widget tree recompute. And because a single Flutter codebase renders on both platforms, one adaptation layer covers Android and iOS at once.</p><p>The platforms underneath are pushing the same direction. Android’s <strong>Material You 3.0</strong> (with Android 16) extracts 20+ semantic color roles on-device — Flutter taps that dynamic palette through the dynamic_color package, so your theme can follow the system&#39;s context-derived colors rather than a hardcoded scheme.</p><p>Here’s dynamic, context-driven theming in Flutter — the ThemeData itself becomes a function of context:</p><pre>class AdaptiveApp extends StatelessWidget {<br>  final UiContext ctx;<br>  const AdaptiveApp({super.key, required this.ctx});</pre><pre>  @override<br>  Widget build(BuildContext context) {<br>    // dynamic_color surfaces Material You palettes from the OS<br>    return DynamicColorBuilder(<br>      builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {<br>        final useDark = ctx.isNight || ctx.batterySaver;</pre><pre>        var scheme = useDark<br>            ? (darkDynamic ??<br>                ColorScheme.fromSeed(<br>                    seedColor: Colors.teal, brightness: Brightness.dark))<br>            : (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.teal));</pre><pre>        // Boost contrast outdoors / bright light<br>        if (ctx.ambientLux &gt; 10000) scheme = scheme.toHighContrast();</pre><pre>        return MaterialApp(<br>          theme: ThemeData(<br>            colorScheme: scheme,<br>            // Larger, more legible type when the user is moving<br>            textTheme: ctx.isMoving ? largeTextTheme : defaultTextTheme,<br>          ),<br>          home: HomeScreen(engine: context.read&lt;ContextEngine&gt;()),<br>        );<br>      },<br>    );<br>  }<br>}</pre><h3>The engine: on-device ML</h3><p>Context-aware UX only works because the inference runs locally. You cannot round-trip to a server to decide whether to enlarge a button while someone is walking — the latency kills it, and shipping motion, location, and camera data to the cloud is a privacy non-starter.</p><p>The Flutter on-device stack is mature. For the model layer you’ve got tflite_flutter (Dart bindings to TensorFlow Lite / LiteRT) and the google_mlkit_* packages for ready-made vision and language tasks; on iOS you can bridge to <strong>Core ML</strong> through a platform channel when you need it. For signals, sensors_plus gives you accelerometer/gyroscope, light reads ambient lux on Android, battery_plus covers power state, and geolocator handles location. The same three-stage pattern maps cleanly onto native Compose/SwiftUI too, if you ever go platform-specific.</p><p>A clean pipeline has three stages: a <strong>signal-collection layer</strong> that samples sensors, system state, and interaction history; an <strong>on-device inference layer</strong> that turns those raw signals into a prediction; and an <strong>adaptation layer</strong> that maps the prediction onto concrete UI state.</p><figure><img alt="Three-stage context-aware UX pipeline: collect signals, infer on-device with TFLite or Core ML in under 1ms, then adapt the Flutter UI" src="https://cdn-images-1.medium.com/max/1024/1*lMbNWdCLNRRH5bkDFTzanQ.png" /></figure><p>Stages one and two — collect signals, run a local model, expose the result as a stream the UI can react to:</p><pre>enum Intent { quickAction, browse }</pre><pre>class UiContext {<br>  final bool isNight;<br>  final bool isMoving;<br>  final double ambientLux;<br>  final bool batterySaver;<br>  final Intent predictedIntent; // model output</pre><pre>  const UiContext({<br>    required this.isNight,<br>    required this.isMoving,<br>    required this.ambientLux,<br>    required this.batterySaver,<br>    required this.predictedIntent,<br>  });</pre><pre>  factory UiContext.defaults() =&gt; const UiContext(<br>        isNight: false, isMoving: false, ambientLux: 300,<br>        batterySaver: false, predictedIntent: Intent.browse);<br>}</pre><pre>class ContextEngine {<br>  final SensorHub sensors;<br>  final Interpreter interpreter; // tflite_flutter</pre><pre>  ContextEngine(this.sensors, this.interpreter);</pre><pre>  // combineLatest4 comes from rxdart<br>  Stream&lt;UiContext&gt; get context =&gt; Rx.combineLatest4(<br>        sensors.activity,      // walking / still / driving<br>        sensors.ambientLight,<br>        sensors.batteryState,<br>        sensors.usageHistory,<br>        (Activity activity, double lux, BatteryState battery, Usage usage) {<br>          final features = featurize(activity, lux, battery, usage, DateTime.now());<br>          final intent = _runInference(features); // &lt;1ms on-device<br>          return UiContext(<br>            isNight: _isNight(DateTime.now()),<br>            isMoving: activity != Activity.still,<br>            ambientLux: lux,<br>            batterySaver: battery.saverOn,<br>            predictedIntent: intent,<br>          );<br>        },<br>      );</pre><pre>  Intent _runInference(List&lt;double&gt; features) {<br>    final output = List.filled(Intent.values.length, 0.0)<br>        .reshape([1, Intent.values.length]);<br>    interpreter.run([features], output); // on-device, no network<br>    return Intent.values[_argmax(output[0] as List&lt;double&gt;)];<br>  }<br>}</pre><p>Stage three — the widget tree is a pure function of that context. Because Flutter rebuilds on stream emissions, the screen literally rearranges itself when the prediction flips:</p><pre>class HomeScreen extends StatelessWidget {<br>  final ContextEngine engine;<br>  const HomeScreen({super.key, required this.engine});</pre><pre>  @override<br>  Widget build(BuildContext context) {<br>    return StreamBuilder&lt;UiContext&gt;(<br>      stream: engine.context,<br>      initialData: UiContext.defaults(),<br>      builder: (context, snapshot) {<br>        final ctx = snapshot.data!;<br>        final actions = adaptiveActions(ctx.predictedIntent);</pre><pre>        if (ctx.predictedIntent == Intent.quickAction) {<br>          // Moving / in a hurry: one big primary action, minimal chrome<br>          return PrimaryActionCard(<br>            key: const Key(&#39;primaryActionCard&#39;),<br>            action: actions.first,<br>            size: ctx.isMoving ? CardSize.large : CardSize.medium,<br>          );<br>        }<br>        // Browsing: richer, denser discovery layout<br>        return DiscoveryGrid(key: const Key(&#39;discoveryGrid&#39;), actions: actions);<br>      },<br>    );<br>  }<br>}</pre><p>Keep the model small and quantized, sample sensors at the lowest rate that still feels responsive, and gate expensive work on battery and thermal state — an adaptive UI that drains the phone defeats its own purpose. In Flutter, push the interpreter.run call onto a background isolate (via compute or a long-lived isolate) if your model is heavy enough to jank the frame.</p><h3>A worked example: the commuter’s transit app</h3><figure><img alt="The same transit app rendered two ways: an 8:12 AM walking, low-battery quick-action screen with Buy morning ticket, versus a 7:40 PM home Wi-Fi browse grid — decided by an on-device model" src="https://cdn-images-1.medium.com/max/1024/1*Aczo20IhMTE2mS-oAYGtYg.png" /></figure><p>Put the pieces together with one concrete flow. A user opens your transit app at 8:12am on a weekday.</p><p>The <strong>signal layer</strong> reports: activity = walking, time = weekday morning, location = within 200m of their usual station, battery = 34%, usage history = buys a single-ride ticket most weekday mornings. The <strong>inference layer</strong> featurizes that and predicts Intent.quickAction with high confidence. The <strong>adaptation layer</strong> renders a stripped-down screen: a single large Buy morning ticket — Station A → Downtown button with a big touch target sized for a walking user, high-contrast colors because ambient light is high, and a dimmed, battery-aware theme. Live departure times sit right below; the trip planner, settings, and promotions collapse behind a menu.</p><p>Same user, same app, 7:40pm, sitting at home on Wi-Fi with the phone charging: activity = still, low ambient light, no imminent commute pattern. Now the model predicts Intent.browse, and the screen renders the full discovery layout — trip planner up front, weekend passes, service alerts, a denser dark-mode interface. Two completely different first screens from one code path, decided on-device in under a millisecond, with no user data leaving the phone.</p><h3>Privacy is what makes it acceptable</h3><p>The reason this is happening <em>now</em> rather than in 2020 is that the privacy story finally holds up. Users have made clear they’ll walk away from apps that feel invasive — surveys this year found a majority lose loyalty to brands that reduce them to statistics. An architecture that ships behavioral data to a server to personalize is exactly the thing they’re rejecting.</p><p>On-device inference flips that. The sensitive signals — location, motion, front-camera emotion cues, usage patterns — never leave the device; only the <em>outcome</em> (a rearranged layout) is visible. Where you do need to improve models across your user base, <strong>federated learning</strong> lets you train on-device and share only gradient updates, and <strong>differential privacy</strong> adds calibrated noise so no individual’s data can be reconstructed from those updates. That combination — local inference plus privacy-preserving training — is what lets you personalize deeply without building a surveillance liability. Treat it as a feature you can market, not a compliance checkbox.</p><h3>The hard part: your UI is now non-deterministic</h3><p>Here’s the challenge teams underestimate. The moment your interface adapts, you’ve given up the thing that made mobile QA tractable: a fixed set of screens. There is no single home screen to design-review or golden-file test anymore — there’s a distribution of home screens, one per context.</p><p>The practical fix is to make context an <em>injectable input</em> so you can pin it in tests. Notice that HomeScreen above takes its context from a stream — in a widget test you feed it a fabricated UiContext and assert the resulting layout:</p><pre>testWidgets(&#39;moving commuter gets the quick action&#39;, (tester) async {<br>  const ctx = UiContext(<br>    isNight: false,<br>    isMoving: true,<br>    ambientLux: 12000,<br>    batterySaver: true,<br>    predictedIntent: Intent.quickAction,<br>  );</pre><pre>  await tester.pumpWidget(<br>    MaterialApp(home: HomeScreen(engine: FakeEngine(ctx))),<br>  );<br>  await tester.pump(); // let the stream emit</pre><pre>  expect(find.byKey(const Key(&#39;primaryActionCard&#39;)), findsOneWidget);<br>  expect(find.byKey(const Key(&#39;discoveryGrid&#39;)), findsNothing);<br>});</pre><p>That reframes the rest of the discipline too. <strong>Design review</strong> shifts from approving a comp to approving <em>rules and bounds</em> — designers specify how far the UI is allowed to move, not one frozen state. <strong>Accessibility</strong> can actually improve here — real-time touch-target enlargement and automatic WCAG-compliant contrast are natural fits for adaptive systems — but only if you make it an explicit adaptation rule rather than hoping it falls out.</p><p>And you have to respect a <strong>stability budget</strong>. A UI that rearranges itself too often is disorienting; users build spatial memory, and moving the button they reach for by reflex is a bug, not a feature. Adapt at sensible boundaries — app launch, a clear context change — not continuously. Over-personalization also risks echo chambers and the creepy-manipulation feeling that erodes trust, so give users a visible, easy way to see and reset how the app has adapted to them.</p><h3>Why it’s worth the complexity</h3><p>The pull is commercial, and the numbers vendors and analysts cite are large: personalized calls-to-action outperform generic ones by around 200%, extensive personalization has been linked to triple-digit lifts in revenue per user, and a majority of business leaders report higher retention from it. Treat the specific figures as directional — they come from marketing research, not controlled trials — but the direction is consistent and it’s why every major platform is now shipping the primitives.</p><p>The static screen was always a compromise: one layout that had to be acceptable for everyone and optimal for no one. On-device ML removes the reason for that compromise. The interface can finally meet each user in their actual moment — walking or sitting, morning or midnight, power user or newcomer — without asking them to configure anything or surrendering their data to do it.</p><p>The screen that never changes is starting to look like the thing that dates your app. In 2026, the interface is a conversation with context — and the apps that learn to hold that conversation are the ones users stay in.</p><p><strong>Part 2 is now up:</strong> <a href="https://medium.com/p/1babc65c38f7">Your Adaptive UI Has No Screenshot</a> — once the interface is built from live context instead of a fixed layout, “does the screen look right?” stops having an answer. Part 2 is about the three ways an adaptive UI breaks, and the tests that catch each one.</p><p><strong>Both articles now have runnable code:</strong> <a href="https://github.com/rudrakshshukla/context_aware_transit">github.com/rudrakshshukla/context_aware_transit</a> — a working Flutter app with the context engine from this article, plus the full test suite from Part 2. No dependencies beyond Flutter itself: clone it and flutter test works straight away.</p><p><em>Sources: TechSling (AI-driven adaptive UI on Android); ParallelHQ (AI personalization in UX); Techqware (Material You 3.0 / Android 16); IEEE and NSF research (privacy-preserving personalized federated learning with differential privacy); Innowise (mobile app development trends 2026). Flutter code samples are illustrative and simplified.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ddd06d25a473" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/the-death-of-the-static-ui-building-context-aware-mobile-apps-in-2026-ddd06d25a473">The Death of the Static UI: Building Context-Aware Mobile Apps in 2026</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I’ve Been a Flutter GDE for 8 Years. Here’s the Ground Truth on “Flutter is Dying”]]></title>
            <link>https://medium.com/flutter-community/ive-been-a-flutter-gde-for-8-years-here-s-the-ground-truth-on-flutter-is-dying-6ffc50ca4088?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/6ffc50ca4088</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[dart]]></category>
            <dc:creator><![CDATA[Randal L. Schwartz]]></dc:creator>
            <pubDate>Wed, 19 Aug 2026 16:02:19 GMT</pubDate>
            <atom:updated>2026-08-19T16:02:18.312Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YGFFX4Hp3uuB5ePQsDijHA.png" /></figure><p>Every few months, like clockwork, the tech blogosphere gets flooded with the same recycled headline: <em>“Is Flutter Dying?”</em>, <em>“Why CTOs Are Quietly Leaving Flutter”</em>, or <em>“Why Google is Killing Its Cross-Platform Bet.”</em></p><p>As someone who has been a <strong>Flutter Google Developer Expert for eight years now — literally from day one of the GDE program</strong> — and a five-decade software industry veteran, I usually just chuckle at the clickbait.</p><blockquote><strong><em>“Flutter is dead.”</em></strong><em> — </em>Said every six months since 2018.<em><br></em><strong><em>Meanwhile:</em></strong><em> </em>Flutter is kicking tail on every single measurable scale.</blockquote><p>The alarmist articles point to standard corporate reorganizations, shifting tech job boards, and “state management fatigue” as evidence of Flutter’s demise. But having watched this ecosystem evolve from an experimental alpha into an enterprise powerhouse, the reality on the ground is the exact opposite.</p><p>Here is the real insider story on what’s actually happening with Dart and Flutter.</p><h3>1. The Inside Story: What Happened at Google?</h3><p>When tech companies restructured engineering teams recently, the internet spun a wild narrative that <em>“Google put Flutter on life support.”</em></p><p>Having direct access to internal teams, I watched the commitment to Dart and Flutter remain steadfast within the organization. However, there <em>was</em> a temporary disconnect: internal engineering activity was roaring, but external communications and public advocacy had slowed down, leaving an information vacuum that clickbait writers eagerly filled.</p><p>I personally called out to team leaders and senior VPs that this perception gap needed immediate correction.</p><p>And the leadership responded strongly:</p><ul><li><strong>Revitalized DevRel &amp; Advocacy:</strong> A renewed surge in active community engagement, tutorials, and public roadmaps.</li><li><strong>Enterprise Adoption Transparency:</strong> Showcasing massive internal and external production milestones.</li><li><strong>Aggressive Core Investment:</strong> Deep work on the Impeller rendering engine, Dart 3.x ergonomics, WebAssembly (Wasm) compilation, and native multiplatform performance.</li></ul><p>Flutter is not a side project at Google; it powers critical business apps across Google (Google Ads, Google Pay, Family Link, Google Classroom) and continues to see deep infrastructure investment.</p><h3>2. The “Hiring Paradox”: Why Flutter Job Postings Look Different</h3><p>Critics often point to public job boards and claim, <em>“Look, there are fewer Flutter job postings than native Android or iOS!”</em></p><p>What they fail to realize is <strong>how enterprises actually adopt Flutter</strong>:</p><blockquote><em>When an enterprise migrates to Flutter, they rarely post 10 new external job listings. Instead, they merge their existing 5-person iOS team and 5-person Android team into a single, unified Flutter team — often cutting total hiring demand in half while doubling feature velocity.</em></blockquote><p>Flutter’s sheer efficiency is what creates the illusion of fewer job listings.</p><p>And when greenfield Flutter positions <em>do</em> open up, junior applicants aren’t competing in a vacuum — they are competing against senior mobile engineers with a decade of native iOS and Android experience who upskilled into Flutter. That’s not a sign of a dying framework; that’s a sign of a <strong>mature, competitive engineering discipline</strong>.</p><h3>3. “State Management Fatigue” Is a Solved Problem</h3><p>Another frequent complaint is that Flutter has “too many state management libraries.”</p><p>Yes, Flutter gave developers freedom. And over the years, the community experimented with everything from ScopedModel and Provider to BLoC, MobX, and Riverpod.</p><blockquote><strong><em>2018 (Streams &amp; Microtasks):</em></strong><em> Heavyweight stream controllers and async microtask queues.<br></em><strong><em>2020 (Provider &amp; Codegen):</em></strong><em> Context lookups, code generation, and complex lifecycles.<br></em><strong><em>2026 (Signals &amp; Modern BLoC):</em></strong><em> Fine-grained reactivity, 0ms synchronous updates, and zero boilerplate.</em></blockquote><p>That evolution isn’t a sign of fragmentation — it’s the natural progress of modern software engineering. We learned what worked (unidirectional data flow, state machines, fine-grained reactivity) and discarded what didn’t (excessive code generation, microtask queue latency, and lingering build dependencies).</p><p>Today, with modern solutions like pure Dart <strong>Signals</strong> and frameworks like <strong>BlocSignal</strong>, state management in Flutter is faster, more synchronous, and more reliable than it has ever been.</p><h3>4. The Measurable Reality: Flutter is Kicking Tail</h3><p>If you want to know whether a framework is healthy, look at the cold, hard data:</p><ul><li><strong>Play Store / App Store Presence:</strong> Over 1,000,000+ Flutter apps published and growing rapidly.</li><li><strong>Rendering Engine:</strong> <strong>Impeller</strong> delivers flawless 60/120fps GPU pipelines, eliminating shader jank once and for all.</li><li><strong>Web Performance:</strong> <strong>Wasm compilation</strong> brings near-native performance to the browser.</li><li><strong>Universal Reach:</strong> Seamless execution across iOS, Android, macOS, Windows, Linux, Embedded, and Web (via Jaspr).</li><li><strong>Language Evolution:</strong> Modern Dart 3.x delivers sealed classes, pattern matching, records, and primary constructors.</li></ul><h3>The Bottom Line</h3><p>Every successful technology goes through the classic hype curve:</p><ol><li><strong>The Novelty Phase</strong> (Everything is magic!)</li><li><strong>The “Trough of Disillusionment”</strong> (Clickbait writers declare it dead when the initial hype normalizes)</li><li><strong>The Plateau of Productivity</strong> (Enterprises quietly build profitable, massive-scale products with it every day)</li></ol><p>Flutter is firmly in the <strong>Plateau of Productivity</strong>. It is mature, stable, blazingly fast, and supported by one of the most vibrant developer communities in software history.</p><p>So the next time you see a headline asking <em>“Is Flutter Dying?”</em>… smile, close the tab, and go build something great. 🚀</p><p><em>Randal L. Schwartz is a Google Developer Expert (GDE) in Dart and Flutter, author of numerous classic programming books, five-decade software industry veteran, and Project Lead for the </em><a href="https://blocsignal.dev/"><em>BlocSignal</em></a><em> ecosystem.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6ffc50ca4088" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/ive-been-a-flutter-gde-for-8-years-here-s-the-ground-truth-on-flutter-is-dying-6ffc50ca4088">I’ve Been a Flutter GDE for 8 Years. Here’s the Ground Truth on “Flutter is Dying”</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Where I’m Writing Now: The Complete 14-Part BlocSignal Series on DEV.to]]></title>
            <link>https://medium.com/flutter-community/where-im-writing-now-the-complete-14-part-blocsignal-series-on-dev-to-2b27b47b00c8?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/2b27b47b00c8</guid>
            <category><![CDATA[bloc]]></category>
            <category><![CDATA[signal]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[state-management]]></category>
            <category><![CDATA[blocsignal]]></category>
            <dc:creator><![CDATA[Randal L. Schwartz]]></dc:creator>
            <pubDate>Mon, 10 Aug 2026 16:00:59 GMT</pubDate>
            <atom:updated>2026-08-10T16:00:58.122Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nGWqexXeiE-5qJ8zyqo7Ng.png" /></figure><p>If you’ve been following my articles here on Medium over the years, thank you! I wanted to share a quick update on where I’m primarily publishing my new technical deep dives, architectural guides, and Flutter/Dart tutorials.</p><p>Going forward, my primary writing hub is <a href="https://dev.to/randalschwartz"><strong>DEV.to (@randalschwartz)</strong></a> and the official <a href="https://dev.to/gde"><strong>Google Developer Experts (GDE)</strong></a> organization on DEV.to.</p><p>DEV.to provides a vibrant developer community, native Dart syntax highlighting, and a built-in Series feature that links connected articles into structured, multi-part curricula.</p><h3>🚀 The New Series: BlocSignal Architecture &amp; Practice</h3><p>Over the last month, my team and I launched <strong>BlocSignal</strong> — a production-grade state management framework for Dart and Flutter that bridges BLoC’s enterprise discipline with Signals’ 0ms synchronous reactivity.</p><p>We’ve published a comprehensive <strong>14-part series on DEV.to</strong> covering everything from core state machine philosophy and Riverpod/Provider interop to dependency injection, OpenTelemetry tracing, and Jaspr web development.</p><p>Here is the complete roadmap of all 14 articles available to read right now on DEV.to:</p><h3>🏛️ 1. Core Architecture &amp; Philosophy</h3><ol><li><a href="https://dev.to/gde/introducing-blocsignal-unidirectional-data-flow-meets-reactive-signals-48b2"><strong>Introducing BlocSignal: Unidirectional Data Flow Meets Reactive Signals</strong></a> <em>The origin story: bridging BLoC semantics with Rody Davis’s signals v7 primitives for 0ms microtask queue latency.</em></li><li><a href="https://dev.to/gde/bloc-meets-signals-how-to-pitch-blocsignal-to-your-dev-leads-3m98"><strong>BLoC meets Signals: How to Pitch BlocSignal to Your Dev Leads</strong></a> <em>How to navigate technical team reviews and explain the enterprise value of synchronous signal graphs.</em></li><li><a href="https://dev.to/gde/how-we-achieved-full-bloc-api-protocol-parity-in-blocsignal-020-mdp"><strong>How We Achieved Full BLoC API &amp; Protocol Parity in BlocSignal 0.2.0</strong></a> <em>A deep dive into </em><em>BlocProvider, </em><em>BlocBuilder, </em><em>BlocListener, </em><em>BlocConsumer, and </em><em>context.select parity.</em></li><li><a href="https://dev.to/gde/beyond-providernotfound-how-blocsignal-rethinks-state-location-and-synchronous-propagation-4ak6"><strong>Beyond ProviderNotFound: How BlocSignal Rethinks State Location and Synchronous Propagation</strong></a> <em>Solving scope lookups and understanding why synchronous signal updates prevent frame tearing.</em></li><li><a href="https://dev.to/gde/from-raw-signals-to-blocsignal-taming-reactivity-for-enterprise-scale-2cmi"><strong>From Raw Signals to BlocSignal: Taming Reactivity for Enterprise Scale</strong></a> <em>How encapsulating signals inside Cubit/Bloc containers stops global signal “spaghetti mutations” in multi-developer teams.</em></li></ol><h3>🔄 2. Ecosystem &amp; Framework Interoperability</h3><ol><li><a href="https://dev.to/gde/switching-tracks-in-blocsignal-the-universal-state-switchyard-for-bloc-riverpod-and-provider-929"><strong>Switching Tracks in BlocSignal: The Universal State Switchyard for BLoC, Riverpod, and Provider</strong></a> <em>Using bidirectional adapters to incrementally migrate or interop across BLoC, Riverpod, and Provider.</em></li><li><a href="https://dev.to/gde/solving-riverpods-family-provider-cache-dilemma-with-signals-mapsignal-474"><strong>Solving Riverpod’s Family Provider Cache Dilemma with Signals &amp; mapSignal</strong></a> <em>How O(1) </em><em>mapSignal keying handles lazy, parameterized caching without complex 3rd-party cache engines or background timers.</em></li><li><a href="https://dev.to/gde/seamless-flutter-hooks-integration-with-blocsignal-via-signalshooks-55e"><strong>Seamless Flutter Hooks Integration with BlocSignal via signals_hooks</strong></a> <em>Combining </em><em>HookWidget lifecycle convenience (</em><em>useSignal, </em><em>useComputed) with </em><em>CubitSignal business logic.</em></li><li><a href="https://dev.to/gde/beyond-flutter-running-blocsignal-state-machines-in-pure-dart-jaspr-web-and-cli-tools-51f6"><strong>Beyond Flutter: Running BlocSignal State Machines in Pure Dart, Jaspr Web, and CLI Tools</strong></a> <em>Demonstrating 100% business logic reuse across Jaspr web SSR/SSG apps, server backends, and Dart CLI tools.</em></li></ol><h3>📐 3. Patterns, Practices &amp; Dependency Injection</h3><ol><li><a href="https://dev.to/gde/why-valuenotifier-fails-at-scale-the-non-composability-problem-and-how-signals-fix-it-4dbc"><strong>Why ValueNotifier Fails at Scale: The Non-Composability Problem (and How Signals Fix It)</strong></a> <em>Why </em><em>ValueNotifier listener chains leak memory and how declarative </em><em>computed() graphs solve composability.</em></li><li><a href="https://dev.to/gde/the-six-flavors-of-dependency-injection-in-flutter-lea"><strong>The Six Flavors of Dependency Injection in Flutter</strong></a> <em>Comparing InheritedWidget, Provider, GetIt, Riverpod, and </em><em>BlocSignalProvider for clean dependency scoping.</em></li><li><a href="https://dev.to/gde/layered-architecture-in-flutter-with-blocsignal-bringing-bloc-discipline-and-signals-speed-to-2c25"><strong>Layered Architecture in Flutter with BlocSignal: Bringing BLoC Discipline and Signals Speed to CodeWithAndrea’s Pattern</strong></a> <em>Applying BlocSignal to CodeWithAndrea’s classic 4-layer Flutter architecture (Data, Repository, Service, Presentation).</em></li><li><a href="https://dev.to/gde/porting-16-bloc-signal-benchmark-apps-to-blocsignal-elevating-flutter-ux-dx-2dnl"><strong>Porting 16 BLoC &amp; Signal Benchmark Apps to BlocSignal: Elevating Flutter UX &amp; DX</strong></a> <em>Lessons learned from porting 16 production benchmark applications to measure end-to-end execution throughput.</em></li><li><a href="https://dev.to/gde/exploring-form-management-patterns-in-flutter-with-blocsignal-1c8j"><strong>Exploring Form Management Patterns in Flutter with BlocSignal</strong></a> <em>Comparing primary vs. derived state, </em><em>CubitSignal methods, and pure reducer </em><em>BlocSignal pattern matching for complex forms.</em></li></ol><h3>📌 Follow Along on DEV.to &amp; The Official Website</h3><p>If you’d like to stay up to date with new articles as they are published:</p><ul><li><strong>Follow me on DEV.to</strong>: <a href="https://dev.to/randalschwartz"><strong>dev.to/randalschwartz</strong></a></li><li><strong>Bookmark the Series Hub</strong>: <a href="https://dev.to/gde/series/42973"><strong>BlocSignal Architecture &amp; Practice Series</strong></a></li><li><strong>Explore Live Demos &amp; Docs</strong>: <a href="https://blocsignal.dev/"><strong>blocsignal.dev</strong></a></li></ul><p>Thank you again for reading, commenting, and following my journey here on Medium. I look forward to seeing you over on DEV.to!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2b27b47b00c8" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/where-im-writing-now-the-complete-14-part-blocsignal-series-on-dev-to-2b27b47b00c8">Where I’m Writing Now: The Complete 14-Part BlocSignal Series on DEV.to</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[GenUI Beyond Chat: Designing a Grammar Book with Flutter GenUI — Part 1]]></title>
            <link>https://medium.com/flutter-community/genui-beyond-chat-building-a-grammar-book-with-flutter-and-caching-the-a2ui-output-with-firebase-ecab7b093ccc?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/ecab7b093ccc</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[firebase]]></category>
            <category><![CDATA[generative-ui]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[genuis]]></category>
            <dc:creator><![CDATA[Cagatay Ulusoy]]></dc:creator>
            <pubDate>Mon, 03 Aug 2026 15:15:45 GMT</pubDate>
            <atom:updated>2026-08-03T15:15:44.165Z</atom:updated>
            <content:encoded><![CDATA[<p>Last year on #FlutterFlightPlans live stream, I asked the Flutter team a question that has stayed with me:</p><blockquote><em>Are there any use cases for Generative UI other than chat responses? Can you share some inspiration?</em></blockquote><p>Seth Ladd answered, “Let’s see what is beyond chat together.” 🚀</p><p>Since that moment, exploring GenUI beyond chat has been my primary focus.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FpGN8kq7QDgg%3Fstart%3D5166%26feature%3Doembed%26start%3D5166&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DpGN8kq7QDgg&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FpGN8kq7QDgg%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/7c53d065806a0337fcf3a8db1412ec82/href">https://medium.com/media/7c53d065806a0337fcf3a8db1412ec82/href</a></iframe><p>I shared my first journey with GenUI in an article series: a <a href="https://medium.com/flutter-community/build-your-own-flutter-genui-framework-with-gemini-structured-outputs-a6db3653b9b6">small GenUI framework built with Gemini structured outputs</a>, and a <a href="https://medium.com/flutter-community/from-structured-outputs-to-a2ui-surfaces-migrating-to-flutter-genui-sdk-4f09aeacee80">migration to Flutter GenUI SDK and A2UI surfaces</a>.</p><p>Then I tried a different kind of use case: a Finnish 🇫🇮 grammar course inside my <em>Finnish it</em> language-learning app.</p><p>In this feature, the learner browses a curriculum in their own language, opens one small grammar topic, and moves through a sequence of Flutter pages.</p><iframe src="https://cdn.embedly.com/widgets/media.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DyqRCdN-5J6Y&amp;type=text%2Fhtml&amp;schema=youtube&amp;display_name=YouTube&amp;src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FyqRCdN-5J6Y%3Ffeature%3Doembed" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/3ac419e10db257e4f9652bdfb9ad5165/href">https://medium.com/media/3ac419e10db257e4f9652bdfb9ad5165/href</a></iframe><h3>Problem Definition</h3><p>I had four constraints:</p><ol><li>I do not speak Finnish, and I am not a language teacher.</li><li>I don’t have time to manually plan and structure a grammar book’s<br> worth of micro-lessons in every language.</li><li>I want interactive application UI, not a long Markdown response.</li><li>Hyper-personalization is crucial. The explanation should begin from the learner’s language. A Turkish learner can approach Finnish vowel harmony through Turkish vowel harmony; an English learner needs another bridge since it has no similar concept.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aDty-xjOlFntPqyu-fLqNQ.png" /><figcaption>Vowel harmony lessons in English and Turkish</figcaption></figure><h4>The same rule does not feel equally foreign</h4><p>That fourth constraint is more than UI localization.</p><p>Turkish speakers will find two familiar concepts in Finnish grammar: attaching grammatical markers directly to the ends of nouns, and changing suffix vowels to match the root word.</p><p>For example, expressing “in” or “at” requires the exact same mental process in both languages:</p><ul><li><strong>Turkish:</strong> <em>ev</em> + <em>-de</em> = <em>evde</em> (in the house)</li><li><strong>Finnish:</strong> <em>talo</em> + <em>-ssa</em> = <em>talossa</em> (in the house)</li></ul><p>An English speaker has a completely different starting point. English relies on separate prepositions (“in,” “from,” “to”) rather than suffixes for spatial relationships.</p><p>For a Turkish learner, the opening can activate an existing instinct: “You already attach location to a noun with endings such as `-de/-da`, and their vowels adapt to the word. Finnish asks you to make a related move with `-ssa/-ssä`, but the cases and usage rules are Finnish.” The explanation can then spend its time on the differences.</p><p>For an English learner, the opening cannot assume that instinct. It should first show that Finnish places part of the job done by “in,” “from,” or “to” inside the noun itself. A visual `talo + -ssa → talossa` breakdown establishes the mental model and the vowel harmony comes after that model is visible.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2RcVFMaqOQ05iNS7K7fp7g.png" /><figcaption>The same grammar lesson shown in Turkish and English</figcaption></figure><p>This is why directly translating a grammar textbook from one language to another rarely works. While the target language is the same, the learner’s starting line is different. What needs to be explicitly taught depends entirely on what already feels familiar.</p><p>This series has three parts:</p><ul><li><strong>Part 1, this article:</strong> the curriculum, teaching system prompt, GenUI catalog, and localized curriculum.</li><li><a href="https://ulusoyca.medium.com/genui-beyond-chat-caching-and-replaying-a2ui-lessons-with-firebase-part-2-76e186a49288"><strong>Part 2</strong></a><strong>:</strong> generating lessons in surface batches and caching their A2UI output in Firestore for replay.</li><li><a href="https://medium.com/p/d1a12eaa4e37"><strong>Part 3</strong></a><strong>:</strong> the Widgetbook integration used to develop and review individual catalog components and complete lessons.</li></ul><p>If you are new to the GenUI, here is the one-paragraph refresher: with GenUI, the model does not write Flutter code. It streams <strong>A2UI</strong> messages — compact JSON that says “create a surface, put these components on it, with this data” — and every component must come from a <strong>catalog</strong> of widgets your app registered up front. A surface is a <em>screen recipe</em>; your widgets do the rendering, and the model becomes a composer rather than a painter.</p><p>Before starting, a strong recommendation is to read the <a href="https://medium.com/flutter-community/from-structured-outputs-to-a2ui-surfaces-migrating-to-flutter-genui-sdk-4f09aeacee80">last part</a> of the previous GenUI article series where A2UI and Flutter GenUi SDK are explained in detail.</p><h3>Designing the Teaching System</h3><h4>Separate what to teach from how to teach it</h4><p>My first version of the problem was too broad:</p><blockquote><em>Generate Finnish grammar lessons.</em></blockquote><p>That instruction delegates nearly every important product decision to the model. Which topics belong in the course? What should an CEFR A1 learner study? Where does one lesson end and the next begin? Which examples, contrasts, and exceptions are required?</p><p>GenUI cannot answer those questions. It can constrain the model to registered widgets and turn its response into an interface, but it cannot determine whether a Finnish explanation is accurate or whether the teaching sequence makes sense.</p><p>I needed to separate three responsibilities:</p><ul><li>The curriculum defines <strong>what to teach</strong>.</li><li>The system prompt defines <strong>how to teach it</strong>.</li><li>The GenUI catalog defines <strong>how the teaching can be expressed as an interface</strong>.</li></ul><h4>The curriculum defines what to teach</h4><p>I organized the curriculum around the CEFR levels and connected them to the YKI levels my learners prepare for. With Gemini’s help, I researched terminology, progression, and examples using references including Leila White’s <a href="https://finnlectura.fi/oppimateriaalit/suomi-toisena-kielena/a-grammar-book-of-finnish/"><em>A Grammar Book of Finnish</em></a> and <a href="https://scripta.kotus.fi/visk/etusivu.php"><em>Iso suomen kielioppi</em></a>.</p><p>The result is:</p><ul><li>6 CEFR levels, from A1 to C2</li><li>30 chapters</li><li>150 grammar lessons</li><li>150 lesson-specific generation briefs</li><li>150 lesson-relation entries</li><li>44 broader grammar concepts</li></ul><p>A lesson is the smallest teachable unit, not a textbook chapter. “The Vowel Harmony Rule and Exceptions” is one lesson. “Short versus Long Sounds” is another.</p><p>That granularity matters. Gemini receives one lesson and its authoritative brief — not the entire course. The brief states what the lesson must cover, what it must exclude, and where its editorial boundary ends.</p><h4>The system prompt defines how to teach it</h4><p>I am not a language teacher. The model performs the teaching, but I define the teaching contract.</p><p>That contract lives in my GrammarLessonPromptBuilder class. I keep it separate from the generation state machine because it is closer to an editorial document than an implementation detail. I should be able to review and rewrite the teaching philosophy without changing how pages are streamed, validated, or cached.</p><p>The prompt first defines the learner. These are adult immigrants and expats living in Finland, learning the Finnish they need at social services, at the doctor, at work, in a supermarket, or on public transport. Many are also preparing for the intermediate YKI test.</p><p>Even the tone is deliberate: warm and matter-of-fact, never condescending, and willing to acknowledge that a difficult point trips up almost everyone at first. Language anxiety is part of the product context.</p><p>The lesson then follows a teaching framework: Concept → Mechanism → Use → Application.</p><ul><li><strong>Concept:</strong> What does this grammar point mean, and why does the learner need it? Where possible, the learner encounters it in a short sentence or dialogue before seeing the rule.</li><li><strong>Mechanism:</strong> How is it formed? The lesson demonstrates the stem, ending, consonant gradation, and vowel harmony, highlighting the exact change.</li><li><strong>Use:</strong> When should the learner choose it? If two forms compete — partitive versus total object, or -ssa versus -lla—the lesson contrasts them and provides a practical decision cue.</li><li><strong>Application:</strong> The learner immediately applies the rule through an interaction instead of only reading about it.</li></ul><p>The interactions also have distinct jobs. QuickCheck tests recognition using plausible learner mistakes, not random distractors. ClozeSentence asks the learner to produce an inflected form in context. Flashcard supports recall of vocabulary, phrases, and irregular forms. Feedback explains both why the answer is correct and which misconception produced the wrong alternatives.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eAX11NY0XTXB4QdCyFYRkQ.png" /></figure><h4>GenUI turns the teaching strategy into an interface</h4><p>This is where GenUI becomes part of the pedagogy rather than merely a presentation layer.</p><p>The system prompt does not simply say, “Make the lesson interactive.” It maps teaching decisions to components. A stem transformation belongs in a visual breakdown. A choice between competing forms belongs in a decision tree or contrast panel. Recognition, production, and recall each have their own interaction.</p><p>The catalog supplies the model’s design vocabulary; the system prompt explains when each part of that vocabulary should be used.</p><p>The ending is constrained as well. The final surface contains a realistic mini-dialogue, three to five key takeaways, and an “Up Next” card whose recommendations are supplied by the app.</p><p>Together, the curriculum, prompt, and catalog govern the complete learning arc: decide what belongs in the lesson, establish its relevance, reveal the pattern, teach the choice, let the learner practise, and close with a real-life use.</p><p>This ordering makes the logic easier to follow because it moves from editorial scope → teaching method → generated interface.</p><h4>Four JSON documents define the course</h4><p>The editorial layer lives in four JSON documents:</p><pre>curriculum.json             hierarchy: levels → chapters → lesson<br>curriculum_topics.json      lesson id → generation brief<br>curriculum_relations.json   lesson id → related lesson ids<br>curriculum_concepts.json    concept id → gloss + lesson ids</pre><p>These documents have different jobs. Combining them on one JSON file would make localization, lesson generation, and cross-feature references harder and expensive.</p><p>Let’s follow one lesson through all four curriculum documents.</p><h4>curriculum.json: hierarchy and learner-facing titles</h4><p>Inside the A1 chapter “The Sound &amp; Spelling of Finnish,” the lesson looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OXA8FOGln3-OXR04hGTpyg.png" /></figure><p>The title is used in the table of contents. The compactTitles let the same lesson appear as shorter discovery prompts in a horizontally sliding carousel without creating duplicate lessons.</p><p>The important field is id. vowel_harmony is not presentation text. It is protocol identity. It is preserved across languages and connects:</p><ul><li>the hierarchy;</li><li>the localized title cache;</li><li>the lesson generation brief;</li><li>related-topic links;</li><li>the concept taxonomy;</li><li>the generated-lesson cache key.</li></ul><h4>curriculum_topics.json: one brief, one fence</h4><p>The hierarchy says that the article exists. The brief says what the generated lesson covers and what it excludes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iWiaE2uHtSstobVqQ3KRlw.png" /><figcaption>Snippet of the curriculum_topics.json file</figcaption></figure><p>For vowel_harmony, the stored value is a plain JSON string containig the following info:</p><blockquote><strong><em>Objective:</em></strong><em> Teach the Vowel Harmony rule for attaching suffixes.</em></blockquote><blockquote><strong><em>Key Concepts:</em></strong><em> Words with back vowels (</em><em>a, </em><em>o, </em><em>u) take back suffixes such as </em><em>-ssa. Words with front vowels (</em><em>ä, </em><em>ö, </em><em>y) take front suffixes such as </em><em>-ssä. Words containing only neutral vowels (</em><em>e, </em><em>i) take front suffixes. In compounds, the ending is determined by the last word.</em></blockquote><blockquote><strong><em>Examples to Include:</em></strong><em> </em><em>talo → talossa, </em><em>työ → työssä, </em><em>tie → tiellä, </em><em>kirjakauppa → kirjakaupassa.</em></blockquote><blockquote><strong><em>Strict Constraints:</em></strong><em> Assume the learner knows the vowel groups. Do not teach consonant changes (</em><em>k, </em><em>p, </em><em>t) or explain the meanings of the cases used.</em></blockquote><p>The exclusions are as important as the objective. Without them, a model can give a useful but badly placed explanation. Vowel harmony lesson can drift into consonant gradation. A lesson about suffix choice can turn into a lesson about noun cases.</p><p>The curriculum is planned in 150 explicit fences each corresponds to a micro lesson.</p><p>The finished prose and surfaces can vary by learner’s native language. The lesson boundary does not.</p><h4>curriculum_relations.json: deterministic next steps</h4><p>The relation document stores curated lesson links. At the end of the each lesson we suggest the next lessons users can read. To do this suggestion the model does not need to know 150-lesson ids and their briefs just to suggest what comes next. The app already knows the allowed IDs and can resolve their localized titles.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SCtodcqlVHYGl2F1xHIl8Q.png" /><figcaption>The last page has suggestion catalog item</figcaption></figure><h4>curriculum_concepts.json: a taxonomy above individual lessons</h4><p>The concept document groups lessons that explain different parts of a broader idea:</p><pre>{<br>  &quot;word_stems_gradation&quot;: {<br>    &quot;gloss&quot;: &quot;Consonant gradation (k/p/t: kk↔k, nt↔nn) and ...&quot;,<br>    &quot;lessons&quot;: [<br>      &quot;quantitative_gradation&quot;,<br>      &quot;qualitative_gradation&quot;,<br>      &quot;reverse_kpt&quot;,<br>      &quot;link_vowel_vartalovokaali&quot;<br>    ]<br>  }<br>}</pre><p>This taxonomy is not another navigation tree. It lets other learning features to connect an observed problem to the lessons that address it. For example, after the learner finishes a speaking practice, and the model notices a broader problem at the feedback phase, it can suggest these set of grouped lessons.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C0RXriF3g93wUdJFzIp7mw.png" /><figcaption>The feedback screen suggests a list of lessons based on user’s mistakes</figcaption></figure><h3>What GenUI Controls — and What It Does Not</h3><p>At this point, the curriculum has decided <strong>what to teach</strong>, and the system prompt has decided <strong>how to teach it</strong>. GenUI has a narrower but equally important responsibility: turning those decisions into an app-owned interface.</p><p>The model does not generate Dart code, Flutter widgets, or pixels. It selects components from a catalog registered by the application and emits A2UI messages describing how those components should be composed.</p><p>Flutter still owns the implementation. The application decides how each component is rendered, how it responds to different screen sizes, how it follows the theme, how accessibility works, and what happens when the learner interacts with it.</p><p>This creates a useful boundary:</p><blockquote>The model chooses the teaching components and their content. The application decides what those components are and how they behave.</blockquote><h4>The catalog is both a design vocabulary and an input cost</h4><p>My grammar catalog contains about forty components, from basic layout primitives to specialized widgets for inflection, pronunciation, grammatical contrasts, and practice.</p><p>The generated UI is only as good as the choices this catalog gives the model. Every component needs a specific purpose and a description that explains when it should and should not be used.</p><p>If two components solve the same problem, the model has to guess between them. That ambiguity produces inconsistent component selection and unpredictable layouts. It also has a direct token cost.</p><p>The catalog is not only a registry that exists inside Flutter. Its component names, descriptions, schemas, example data, and additional usage rules become part of the model’s input. Every catalog item makes that fixed input block larger, and the block is sent again on every provider turn.</p><p>Input tokens are usually cheaper than output tokens, but they are not free especially when the same catalog is repeatedly attached to a multi-turn generation flow. A component added for one rare lesson still contributes to the input cost of every lesson.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/751/1*hDXpDtt2YSCMXMBE0NU__g.png" /><figcaption>Input price is usually cheaper than output price, but still can be expensive</figcaption></figure><p>This creates two competing pressures.</p><ul><li><strong>Clarity and orthogonality.</strong> Components should have distinct pedagogical responsibilities. A stem transformation, a comparison between competing forms, and a vocabulary-recall exercise represent different teaching operations and deserve different components. Orthogonality improves both model selection and token efficiency because the catalog does not spend tokens describing several ways to perform the same job.</li><li><strong>Variety versus token cost.</strong> More components give the model more ways to create visually engaging lessons, but each new schema increases the fixed input cost. The goal is therefore not to build the largest possible catalog. It is to build the smallest catalog that still gives the model the semantic vocabulary it needs.</li></ul><p>A useful test is:</p><blockquote>Does the model need to make this decision, or can Flutter make it more reliably?</blockquote><p>Consider tabular data. A table works well on a large screen, while vertically stacked cards may be easier to read on a phone or when translated text becomes long. I could register DataTable and DataCards as separate choices and ask the model to select one. But that adds another decision, another schema, and more input tokens—even though the difference is primarily responsive presentation.</p><p>Instead, the catalog can expose one semantic data component. The model supplies the headings and rows; Flutter chooses whether to render them as a table or cards using the available width, text length, and accessibility settings.</p><p>The model decides <strong>what the data means</strong>. Flutter decides <strong>how that data fits the screen</strong>. This distinction lets me preserve catalog variety where it improves teaching while removing choices that the model should never have been asked to make.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8lys0btEb0veR8PXPR7Vqg.png" /><figcaption>DataTable vs DataCards in Widgetbook</figcaption></figure><p>This fixed catalog cost becomes important when a lesson requires several provider turns. In Part 2, I will show why I generate several surfaces per turn instead of paying that input cost once per page.</p><h4>From catalog schema to Flutter widget: CaseCard</h4><p>All of this is easier to understand through one concrete catalog item.</p><p>CaseCard introduces or recaps one Finnish grammatical case. It shows the case name, its everyday meaning, the question it answers, its endings, and an optional worked example.</p><p>The visible CaseCard is an ordinary Flutter design-system widget. It does not import GenUI, know about Gemini, or parse A2UI. Its GenUI contract lives in a separate CatalogItem adapter.</p><p>The adapter has four responsibilities:</p><ul><li>Give the component a stable name.</li><li>Describe when the model should choose it.</li><li>Define the data the model may provide.</li><li>Convert that data into the ordinary Flutter widget.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XNUMXuCIuPNIiFeiZaRQ-A.png" /><figcaption>CaseCard genui catalog item in Widgetbook</figcaption></figure><pre>final _schema = S.object(<br>  description:<br>      &#39;A reference / identity card for ONE grammatical case: its name and &#39;<br>      &#39;everyday meaning as a title, the question word it answers and the case &#39;<br>      &#39;ending shown as chips, and one worked example (base word → inflected &#39;<br>      &#39;form with the ending highlighted). Pick this to introduce or recap a &#39;<br>      &#39;single case such as the Inessive, Adessive, or Illative. Use EndingRules &#39;<br>      &#39;when the rule branches on the word ending, or InflectionBreakdown for a &#39;<br>      &#39;staged build-up of one word.&#39;,<br>  properties: {<br>    &#39;caseName&#39;: S.string(<br>      description:<br>          &#39;The case name, e.g. &quot;Inessiivi&quot;. Inline Markdown emphasis is &#39;<br>          &#39;supported.&#39;,<br>    ),<br>    &#39;meaning&#39;: S.string(<br>      description:<br>          &#39;The everyday meaning shown after the name, e.g. &quot;inside something&quot;.&#39;<br>          &#39; Inline Markdown emphasis is supported.&#39;,<br>    ),<br>    &#39;question&#39;: S.string(<br>      description:<br>          &#39;The question word the case answers, e.g. &quot;Missä?&quot;. Inline Markdown &#39;<br>          &#39;emphasis is supported.&#39;,<br>    ),<br>    &#39;ending&#39;: S.string(<br>      description:<br>          &#39;The case ending(s), e.g. &quot;-ssa / -ssä&quot;. Inline Markdown emphasis &#39;<br>          &#39;is supported.&#39;,<br>    ),<br>    &#39;exampleLabel&#39;: S.string(<br>      description:<br>          &#39;Optional label above the example, e.g. &quot;Esimerkki (Example)&quot;. &#39;<br>          &#39;Inline Markdown emphasis is supported.&#39;,<br>    ),<br>    &#39;exampleFrom&#39;: S.string(<br>      description:<br>          &#39;The example base word, e.g. &quot;Helsinki&quot;. Inline Markdown emphasis &#39;<br>          &#39;is supported.&#39;,<br>    ),<br>    &#39;exampleTo&#39;: S.string(<br>      description:<br>          &#39;The example inflected form; wrap the case ending in **bold** to &#39;<br>          &#39;highlight it, e.g. &quot;Helsingi**ssä**&quot;.&#39;,<br>    ),<br>    &#39;exampleFromGloss&#39;: S.string(<br>      description:<br>          &#39;Optional gloss under the base word, e.g. &quot;Helsinki&quot;. Inline &#39;<br>          &#39;Markdown emphasis is supported.&#39;,<br>    ),<br>    &#39;exampleToGloss&#39;: S.string(<br>      description:<br>          &#39;Optional gloss under the inflected form, e.g. &quot;in Helsinki&quot;. &#39;<br>          &#39;Inline Markdown emphasis is supported.&#39;,<br>    ),<br>  },<br>  required: [&#39;caseName&#39;],<br>);<br><br><br>final caseCard = CatalogItem(<br>  name: &#39;CaseCard&#39;,<br>  dataSchema: _schema,<br>  exampleData: [_buildExample],<br>  widgetBuilder: (context) {<br>    final map = context.data as Map&lt;String, Object?&gt;;<br><br>    final exampleFrom = map[&#39;exampleFrom&#39;] as String?;<br>    final exampleTo = map[&#39;exampleTo&#39;] as String?;<br>    final hasExample =<br>        exampleFrom != null &amp;&amp;<br>        exampleFrom.isNotEmpty &amp;&amp;<br>        exampleTo != null &amp;&amp;<br>        exampleTo.isNotEmpty;<br><br>    return GenUiCatalogItemSection(<br>      child: CaseCard(<br>        caseName: (map[&#39;caseName&#39;] as String?) ?? &#39;&#39;,<br>        meaning: map[&#39;meaning&#39;] as String?,<br>        question: map[&#39;question&#39;] as String?,<br>        ending: map[&#39;ending&#39;] as String?,<br>        example: hasExample<br>            ? CaseExample(<br>                label: map[&#39;exampleLabel&#39;] as String?,<br>                from: exampleFrom,<br>                to: exampleTo,<br>                fromGloss: map[&#39;exampleFromGloss&#39;] as String?,<br>                toGloss: map[&#39;exampleToGloss&#39;] as String?,<br>              )<br>            : null,<br>      ),<br>    );<br>  },<br>);</pre><p>The description establishes catalog item’s boundary for the model.</p><p>CaseCard is appropriate when the lesson needs an identity card for one grammatical case. It is not the right component when the rule branches according to a word ending or when the learner needs to see a transformation in several stages.</p><p>This is orthogonality in practice: the catalog does not merely tell the model what components exist; it explains which teaching operation belongs to each one.</p><p>When Gemini selects CaseCard, its A2UI output contains component data shaped like this:</p><pre>{<br>  &quot;id&quot;: &quot;inessive_case&quot;,<br>  &quot;component&quot;: {<br>    &quot;CaseCard&quot;: {<br>      &quot;caseName&quot;: &quot;Inessiivi&quot;,<br>      &quot;meaning&quot;: &quot;inside something&quot;,<br>      &quot;question&quot;: &quot;Missä?&quot;,<br>      &quot;ending&quot;: &quot;-ssa / -ssä&quot;,<br>      &quot;exampleLabel&quot;: &quot;Example&quot;,<br>      &quot;exampleFrom&quot;: &quot;Helsinki&quot;,<br>      &quot;exampleFromGloss&quot;: &quot;Helsinki&quot;,<br>      &quot;exampleTo&quot;: &quot;Helsingi**ssä**&quot;,<br>      &quot;exampleToGloss&quot;: &quot;in Helsinki&quot;<br>    }<br>  }<br>}</pre><p>This component arrives as part of an A2UI updateComponents message. GenUI finds the registered item named CaseCard, checks the supplied data against its schema, and passes the data to widgetBuilder. The builder constructs the ordinary Flutter CaseCard, and the application handles the actual rendering.</p><p>The model never receives the Flutter layout implementation. It only sees the component’s purpose, schema, and example data.</p><p>That gives each layer one clear responsibility:</p><ul><li><strong>System prompt </strong>→ why the lesson needs this teaching operation</li><li><strong>Catalog description</strong> → when CaseCard should be selected</li><li><strong>JSON schema </strong>→ which data CaseCard accepts</li><li><strong>A2UI message</strong> → the generated values for this instance</li><li><strong>Widget builder</strong> → how those values enter the Flutter widget</li><li><strong>Flutter widget</strong> → layout, theme, accessibility, and rendering</li></ul><h3>The curriculum itself needs localization</h3><p>Before a learner generates anything, they need to browse the course.</p><p>The hierarchy contains learner-facing level, chapter, lesson, and compact titles. I treat each localized curriculum as a generated cache derived from one English source.</p><p>The Firestore layout for the curriculum is:</p><pre>// Each curriculum is a localized document under grammar_curriculum collection<br>grammar_curriculum/en          canonical English curriculum<br>grammar_curriculum/tr          complete Turkish curriculum cache<br>grammar_curriculum/es          complete Spanish curriculum cache<br>grammar_curriculum/{language}  complete target-language cache<br><br>// Below collections do not need localization since they are not user-facing<br>grammar_curriculum/topics      lesson generation briefs<br>grammar_curriculum/relations   language-independent relation graph<br>grammar_curriculum/concepts    language-independent concept taxonomy</pre><p>These are documents in one collection. The English document contains the canonical json. A target language document contains a complete localized json plus synchronization metadata.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/906/1*YZaloLHIU2rIv7gFY2Em4Q.png" /><figcaption>Screenshot of the grammar_curriculum collection in Firestore</figcaption></figure><p>The curriculum translation is not direct translation from English. It is done using LLM with the following prompt:</p><pre>&#39;&#39;&#39;<br># ROLE<br>You are an expert educational localizer for a Finnish grammar curriculum. Readers are adult learners of Finnish who study the grammar in Finnish but use the app in their own language.<br><br># TASK<br>Localize the supplied English grammar curriculum labels into ${targetLanguageInstruction}. Every label is a short UI title on a lesson card, so keep each translation about as short as its source.<br><br># RULES<br>1. Return exactly one output unit per input unit and copy each \`key\` unchanged.<br>2. Translate meaning, not words: use the grammar terminology a native speaker of the target language meets in language textbooks, and translate recurring terms (partitive, stem, suffix, case, verb type...) the same way in every unit.<br>3. Keep every piece of Finnish exactly as written — example words and sentences (lapsi ➔ lapse-, Minulla ei ole autoa), endings and suffix patterns (-ssa/-ssä, -ni, -si, -nsa), Finnish grammar names (Genetiivi, Vartalovokaali, Lauseenvastikkeet), and terms such as YKI, Kela, puhekieli, kirjakieli: the Finnish is what the learner is studying. Translate only the non-Finnish text around it, and keep symbols such as ➔, /, &quot;...&quot;, and hyphens in place.<br>4. When English merely explains Finnish — usually a gloss in parentheses like &quot;rahatta (without money)&quot; — translate that explanation. When a label leans on English itself (for example the possessive &quot;&#39;s&quot;), adapt the comparison so it makes sense to a reader of the target language.<br>5. Keep CEFR codes (A1, B2...) verbatim. Never leave English wording untranslated; only Finnish material, proper nouns, and codes pass through unchanged.<br>6. Keep the friendly, curious tone. Recreate wordplay and humor naturally in the target language instead of translating it literally; when a pun cannot carry over, write a natural label with the same meaning.<br>7. Translate \`compactTitles\` item by item: same count, same order. Output \`compactTitles\` only for units whose input has \`compactTitles\`; otherwise output only \`title\`.<br>8. Return only JSON matching the response schema.<br><br># EXAMPLE<br>Input unit: {&quot;key&quot;:&quot;article:abessive_case&quot;,&quot;title&quot;:&quot;Without (Abessive Case)&quot;,&quot;compactTitles&quot;:[&quot;rahatta (without money)&quot;,&quot;The -tta / -ttä ending&quot;,&quot;Abessiivi&quot;]}<br>A correct output translates the title using the target language&#39;s name for the abessive case, keeps &quot;rahatta&quot; and translates only its parenthetical gloss, keeps &quot;-tta / -ttä&quot; while translating &quot;The ... ending&quot;, and returns &quot;Abessiivi&quot; unchanged.<br><br># INPUT<br>${JSON.stringify(units.map((unit) =&gt; ({ key: unit.key, ...unit.payload })))}<br>&#39;&#39;&#39;</pre><h3>Closing: the lesson now has boundaries</h3><p>At this point, the system has the pieces required to generate a lesson:</p><ul><li>A stable lesson ID and a brief</li><li>A CEFR level</li><li>A learner explanation language</li><li>A teaching contract</li><li>A constrained catalog of Flutter components</li><li>A localized curriculum through which the learner can find the lesson</li></ul><p>The curriculum controls what belongs in the lesson. The system prompt controls how it should be taught. The catalog controls how that teaching can be expressed as an interface.</p><p>That creates deterministic boundaries around generation, but generation itself still costs time and tokens.</p><p>When the first Turkish learner opens vowel_harmony, Gemini can compose the lesson. When the next Turkish learner opens the same lesson, generating it again would repeat the same expensive work.</p><p>The output already contains everything Flutter needs to rebuild the lesson: the A2UI messages that describe its surfaces and components.</p><p><a href="https://ulusoyca.medium.com/genui-beyond-chat-caching-and-replaying-a2ui-lessons-with-firebase-part-2-76e186a49288">In Part 2</a>, I will store that A2UI output in Firestore and feed it back through the same GenUI transport. On a miss, Gemini supplies the lesson recipe. On a hit, Firestore supplies it.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ecab7b093ccc" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/genui-beyond-chat-building-a-grammar-book-with-flutter-and-caching-the-a2ui-output-with-firebase-ecab7b093ccc">GenUI Beyond Chat: Designing a Grammar Book with Flutter GenUI — Part 1</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Make scalable routing system for your Flutter Apps]]></title>
            <link>https://medium.com/flutter-community/make-scalable-routing-system-for-your-flutter-apps-73510bf3d602?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/73510bf3d602</guid>
            <category><![CDATA[routing-system]]></category>
            <category><![CDATA[go-router]]></category>
            <category><![CDATA[clean-architecture]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[modular-router]]></category>
            <dc:creator><![CDATA[Kanan Yusubov]]></dc:creator>
            <pubDate>Wed, 29 Jul 2026 15:25:43 GMT</pubDate>
            <atom:updated>2026-07-29T15:25:41.658Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2u0q44bxdCXbmV4nbJ7sGQ.png" /></figure><p>Recently, I’ve been working on migrating our app’s routing system. We previously used <strong>auto_route</strong>, but eventually realized its tightly coupled nature made it incredibly difficult to scale across independent modules. Furthermore, its heavy reliance on code generation became a bottleneck for our workflow.</p><p>To resolve this, we decided to architect a clean, scalable, and highly decoupled routing system. Our new solution is a powerful wrapper built on top of <strong>go_router</strong>. It abstracts away the raw string navigation and supercharges the router with custom features to maximize usability and type safety.</p><p>In this blog post, I will walk you through the core benefits of this new routing system, covering the following key topics:</p><ul><li>Unified Routing Protocol (AppRouteInfo)</li><li>Strictly Typed Route Arguments</li><li>Native &amp; Path Parameters Support</li><li>Cross-Module Modularization (AppModuleRouter)</li><li>Hierarchical Back stack &amp; Sub-Routes (Nested Route Definitions, Decoupled Cross-Module Navigation)</li><li>Dynamic Presentation Modes (e.g., Native, ModalSheet, NoTransition)</li><li>Guard Interceptor Mechanism (Type-Safe Redirects, Global Interceptors)</li></ul><blockquote>Note: This blog will show the general usage and problems it solved. For more detailed code, you can check the following link: <a href="https://github.com/thisisyusub/scalable-routing-system-demo">https://github.com/thisisyusub/scalable-routing-system-demo</a></blockquote><h4>Architectural Structure of Routing System</h4><p>Here is how the demo application is structured to achieve a fully decoupled, scalable routing system:</p><ul><li><strong>scalable_routing_system_demo (main app)</strong>: The entry point of the application responsible for wiring up all services, dependency injection, and global route configurations.</li><li><strong>app_routes</strong>: Houses all route definitions, parameters, routing protocols, and models to ensure fully type-safe navigation. This is decoupled from the core routing package to separate app-specific business needs from generic routing mechanics.</li><li><strong>routing_system (core router)</strong>: A generic, domain-agnostic router package that has zero knowledge of the app’s specific routes. It exclusively provides the underlying routing configuration and composition mechanisms.</li><li><strong>home (shell route)</strong>: Acts as the parent shell route wrapping the posts and settings modules as its children. Notably, it has <strong>no direct dependencies</strong> on them—a pattern achieved through architectural contracts and dependency injection.</li><li><strong>posts</strong> &amp; <strong>settings (feature modules)</strong>: Independent feature modules that manage their own internal views while exposing or exporting specific routes through shared contracts.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*M-tOOjAc6vffwtP7F-f7jQ.png" /></figure><h4>Initialization of the routing system (DI)</h4><p>The routing system allows us to easily set up a project using simple class initialization. As you can see, it configures all routes based on a modular approach. We will explore modular route definitions in more detail shortly.</p><pre>RouterConfig&lt;Object&gt; initRouterConfig() {<br>  final routerConfig = AppGoRouterConfig(<br>    initialLocation: AuthRoutesInfo.login,<br>    modules: [<br>      AuthModuleRouter(),<br>      HomeModuleRouter(<br>        postViewCompositionApi: getIt(),<br>        settingViewCompositionApi: getIt(),<br>      ),<br>      PostsModuleRouter(),<br>    ],<br>    guards: [],<br>    observers: [],<br>    debugLogDiagnostics: kDebugMode,<br>  );<br><br>  getIt.registerSingleton&lt;AppRouterConfig&gt;(routerConfig);<br><br>  getIt.registerSingleton&lt;AppNavigationService&gt;(<br>    GoRouterNavigationService(getIt&lt;AppRouterConfig&gt;().config),<br>  );<br><br>  return routerConfig.config;<br>}</pre><h4>Route Info Definition &amp; Typed Safe Route Setup</h4><p>First, we need to set up the route information for our modules. To achieve this, we use the app_routes module. For example, here is how the routes are defined for the auth module.</p><p>Under the hood, we rely on a protocol named AppRouteInfo, which establishes the standard guidelines for setting up route information.</p><pre>final class AuthRoutesInfo extends AppRouteInfo {<br>  const AuthRoutesInfo._({required super.name, required super.path});<br><br>  static const login = AuthRoutesInfo._(name: &#39;login&#39;, path: &#39;/login&#39;);<br>  static const register = AuthRoutesInfo._(name: &#39;register&#39;, path: &#39;/register&#39;);<br>  static const forgotPassword = AuthRoutesInfo._(<br>    name: &#39;forgot-password&#39;,<br>    path: &#39;/forgot-password&#39;,<br>  );<br>}</pre><p>Next, we write helper functions to enable type-safe navigation. (Note that we aren’t using query or path parameters for these specific routes just yet, but the structure is ready for them.)</p><pre>@internal<br>final class AuthRoutes {<br>  AppRouteRequest login() =&gt;<br>      const AppRouteRequest(routeInfo: AuthRoutesInfo.login);<br>  AppRouteRequest register() =&gt;<br>      const AppRouteRequest(routeInfo: AuthRoutesInfo.register);<br>  AppRouteRequest forgotPassword() =&gt;<br>      const AppRouteRequest(routeInfo: AuthRoutesInfo.forgotPassword);<br>}</pre><p>Finally, we group all these route functions together into a single class, like this:</p><pre>abstract final class AppRoutes {<br>  static final auth = AuthRoutes();<br>  static final post = PostRoutes();<br>  static final setting = SettingRoutes();<br>}</pre><h4>How to setup ModuleRouter?</h4><p>Each module defines its own routes by implementing the AppModuleRouter protocol. (Some modules might only be used as sub-routes inside other modules and don&#39;t need global registration, but we will look at that later.) Here is an example of the AuthModuleRouter:</p><pre>final class AuthModuleRouter implements AppModuleRouter {<br>  @override<br>  List&lt;AppModuleRoute&gt; get routes =&gt; [<br>    AppPageRoute(<br>      routeInfo: AuthRoutesInfo.login,<br>      argsParser: RouteNoArgs.fromRaw,<br>      routeBuilder: (_, _) =&gt; const LoginView(),<br>    ),<br>    AppPageRoute(<br>      routeInfo: AuthRoutesInfo.register,<br>      presentationMode: const BottomSheetPresentationMode(),<br>      argsParser: RouteNoArgs.fromRaw,<br>      routeBuilder: (_, _) =&gt; const RegisterView(),<br>    ),<br>    AppPageRoute(<br>      routeInfo: AuthRoutesInfo.forgotPassword,<br>      presentationMode: const DialogPresentationMode(),<br>      argsParser: RouteNoArgs.fromRaw,<br>      routeBuilder: (_, _) =&gt; const ForgotPasswordView(),<br>    ),<br>  ];<br>}</pre><ul><li><strong>AppModuleRouter Protocol</strong>: Every feature module implements this protocol to declare its own routing table. This keeps routing decentralized — the core app doesn’t need to know how individual features are built internally; it just aggregates these routers</li><li><strong>AppPageRoute Configuration:</strong> Each entry maps a type-safe <strong>routeInfo</strong> to a specific widget builder (<strong>routeBuilder</strong>).</li><li><strong>Flexible Presentation Modes:</strong> Notice how modularity shines here. While the LoginView behaves as a standard full-screen page, the <strong>RegisterView</strong> is configured to open as a <strong>BottomSheet</strong>, and the <strong>ForgotPasswordView</strong> opens as a <strong>Dialog</strong>. The routing system handles these custom presentation transitions seamlessly out of the box.</li><li><strong>RouteNoArgs.fromRaw:</strong> Since these specific auth routes don&#39;t require path or query parameters yet, we use a zero-argument parser to keep things clean and strictly typed.</li></ul><h4>How can we navigate?</h4><p>We have two options for handling navigation: using the <strong>BuildContext</strong> directly or utilizing the <strong>AppNavigationService</strong>.</p><p>Since we register the <strong>AppNavigationService</strong> during our application initialization process, any Cubit, BLoC, or other business logic component can take it as an abstract dependency. This allows for clean, framework-independent navigation right from your business layer.</p><p>For example, if we want to navigate to one of our Auth routes, we can use the following methods:</p><pre>// using context<br> context.goRoute(AppRoutes.auth.login());<br> context.pushRoute(AppRoutes.auth.login());<br> context.replaceRoute(AppRoutes.auth.login());<br><br>// using AppNavigationService (it is totally same)<br>final navService = getIt.get&lt;AppNavigationService&gt;();<br>navService.goRoute(AppRoutes.auth.login());<br>navService.pushRoute(AppRoutes.auth.login());<br>navService.replaceRoute(AppRoutes.auth.login());</pre><p>Additionally, the system provides standard stack-management helpers such as canPopRoute, popRoute, maybePopRoute, and popUntilRoute to cover all your navigational flow requirements.</p><h4>How some module can export its views for other modules to use as sub-routes?</h4><p>For example, in our case, the home module needs pages from the posts and settings modules to use them as sub-routes (specifically, as its shell branches). As you can see, the home view displays both posts and settings as its inner views.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fhdk1aaFdJZY3KDuZUqXXA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HJe8wrvQrSDTQOtOcjftSw.png" /></figure><p>Directly exporting UI widgets from a feature module and importing them into the <strong>home</strong> module creates tightly coupled dependencies, which goes against our clean architecture goals. Instead, I solved this by introducing architectural contracts and composition protocols. Let me show you how this is implemented using the <strong>posts</strong> module as an example.</p><p>First, we create an interface (protocol) inside the <strong>app_routes</strong> module specifically for the posts feature. This allows us to define clean getter functions for each view or route that the posts module exposes for composition.</p><pre>/// Defines the UI components the Posts feature exposes to be<br>/// embedded inside other feature layouts.<br>abstract interface class PostViewCompositionApi {<br>  /// The route to be used as a tab or nav rail item in the home navigation.<br>  AppModuleRoute get homeShellRoute;<br>}</pre><p>Then, inside the <strong>posts</strong> module, we create the concrete implementation of this composition API and register it in our service locator so it can be consumed without direct coupling:</p><pre>/// post_view_composition_impl.dart -&gt; [posts] module<br>final class PostViewCompositionImpl implements PostViewCompositionApi {<br>  @override<br>  AppModuleRoute get homeShellRoute =&gt; AppPageRoute(<br>    routeInfo: PostRouteInfo.homePostsTab,<br>    argsParser: RouteNoArgs.fromRaw,<br>    routeBuilder: (context, args) =&gt; const PostsView(),<br>  );<br>}<br><br>/// post_route_info.dart -&gt; [app_routes] module -&gt; post folder<br>final class PostRouteInfo extends AppRouteInfo {<br>  const PostRouteInfo._({required super.name, required super.path});<br><br>  static const homePostsTab = PostRouteInfo._(<br>    name: &#39;home_posts_tab&#39;,<br>    path: &#39;/home/posts&#39;,<br>  );<br><br>  /// will use it later for inner screen case<br>  static const postDetails = PostRouteInfo._(<br>    name: &#39;post_details&#39;,<br>    path: &#39;/posts/:id&#39;,<br>  );<br>}<br><br>/// post_di.dart -&gt; [post] module (we call it in the main app)<br>final class PostDI {<br>  static Future&lt;void&gt; init() async {<br>    GetIt.instance.registerFactory&lt;PostViewCompositionApi&gt;(<br>      () =&gt; PostViewCompositionImpl(),<br>    );<br>  }<br>}</pre><p>Finally, we can consume this composition API inside our <strong>HomeModuleRouter</strong> to seamlessly define the posts and settings views as child shell routes, entirely decoupling the home module from the internal implementation details of those features:</p><pre>final class HomeModuleRouter implements AppModuleRouter {<br>  const HomeModuleRouter({<br>    required this.postViewCompositionApi,<br>    required this.settingViewCompositionApi,<br>  });<br><br>  final PostViewCompositionApi postViewCompositionApi;<br>  final SettingViewCompositionApi settingViewCompositionApi;<br><br>  @override<br>  List&lt;AppModuleRoute&gt; get routes =&gt; [<br>    AppStatefulShellRoute(<br>      shellBuilder: (context, appStatefulShell, child) =&gt;<br>          HomeView(appStatefulShell: appStatefulShell, child: child),<br>      branches: [<br>        AppStatefulShellBranch(routes: [postViewCompositionApi.homeShellRoute]),<br>        AppStatefulShellBranch(<br>          routes: [settingViewCompositionApi.homeShellRoute],<br>        ),<br>      ],<br>    ),<br>  ];<br>}</pre><p>Once we define the helper functions for our post routes like this, we can invoke our navigation requests easily and in a fully type-safe manner:</p><pre>@internal<br>final class PostRoutes {<br>  AppRouteRequest homePostTab() =&gt; const AppRouteRequest(routeInfo: PostRouteInfo.homePostsTab);<br>}</pre><pre>context.replaceRoute(AppRoutes.post.homePostTab());</pre><h4>How we can define query &amp; path params and extras?</h4><p>In our demo app, when a user clicks on a post item, it opens the post details screen by passing the unique post ID. To ensure type-safe parameter handling, we create a dedicated arguments class inside the <strong>app_routes</strong> module (under the posts directory):</p><pre>final class PostDetailsArgs {<br>  const PostDetailsArgs({<br>    required this.id,<br>    // this.viewAsPublic = false,<br>    // this.extraInfo,<br>  });<br><br>  final String id;<br><br>  // final bool viewAsPublic;<br>  // final String? extraInfo;<br><br>  static PostDetailsArgs fromRaw(AppRouteArguments rawArgs) {<br>    return PostDetailsArgs(<br>      id: rawArgs.pathParameters[&#39;id&#39;] ?? &#39;&#39;,<br>      // viewAsPublic: rawArgs.queryParameters[&#39;view_as_public&#39;] == &#39;true&#39; ? true : false,<br>      // extraInfo: rawArgs.extra as String?,<br>    );<br>  }<br><br>  AppRouteRequest toRequest() =&gt; AppRouteRequest(<br>    routeInfo: PostRouteInfo.postDetails,<br>    pathParameters: {&#39;id&#39;: id},<br>    // queryParameters: {&#39;view_as_public&#39;: viewAsPublic ? &#39;true&#39; : &#39;false&#39;},<br>    // extra: extraInfo,<br>  );<br>}</pre><p>While we only used path parameters in this example, this argument class pattern can easily be extended to support <strong>query parameters</strong>,<strong> extra data objects</strong>, or any other required types.</p><p>Next, within our <strong>PostsModuleRouter</strong>, we wire up the argument parser to map the raw route data directly to our <strong>PostDetailsArgs</strong> class:</p><pre>final class PostsModuleRouter implements AppModuleRouter {<br>  @override<br>  List&lt;AppModuleRoute&gt; get routes =&gt; [<br>    AppPageRoute(<br>      routeInfo: PostRouteInfo.posts,<br>      argsParser: RouteNoArgs.fromRaw,<br>      routeBuilder: (context, args) =&gt; const PostsView(),<br>    ),<br>    AppPageRoute(<br>      routeInfo: PostRouteInfo.postDetails,<br>      argsParser: PostDetailsArgs.fromRaw,<br>      // it automatically casts args to PostDetailsArgs in [routerBuilder]<br>      // so we can safely use args.id (IDE gives its fields)<br>      routeBuilder: (context, args) =&gt; PostDetailsView(postId: args.id),<br>    ),<br>  ];<br>}</pre><p>Once again, to achieve type-safe navigation, we rely on our helper functions:</p><pre>/// post_routes.dart<br>@internal<br>final class PostRoutes {<br>  AppRouteRequest homePostTab() =&gt; const AppRouteRequest(routeInfo: PostRouteInfo.homePostsTab);<br>  AppRouteRequest postDetails({required String id}) =&gt; PostDetailsArgs(id: id).toRequest();<br>}<br><br>/// post_route_info.dart<br>static const postDetails = PostRouteInfo._(<br>  name: &#39;post_details&#39;,<br>  path: &#39;/posts/:id&#39;,<br>);</pre><p>Because it’s configured as an independent route rather than a child of the shell, it opens globally on top of the home screen (hiding the bottom navigation bar).</p><p>Finally, we can invoke it anywhere in our UI cleanly and safely:</p><pre>context.pushRoute(AppRoutes.post.postDetails(id: post.id));</pre><h4>Role of AppRouteWrapper for Providers</h4><p>When working with BLoCs, Cubits, or ChangeNotifier instances that depend on the widget tree, we often need to wrap our routes with their respective providers. However, doing this directly inside the router definition can quickly clutter the code and make it hard to maintain.</p><p>To solve this, I implemented an <strong>auto_route</strong>-style wrapping mechanism. For example, if we have a Cubit like this:</p><pre>class CounterCubit extends Cubit&lt;int&gt; {<br>  CounterCubit() : super(0);<br><br>  void increase() =&gt; emit(state + 1);<br><br>  void decrease() =&gt; emit(state - 1);<br>}</pre><p>We can provide it cleanly by having our view implement the <strong>AppRouteWrapper</strong> protocol:</p><pre>class CounterView extends StatelessWidget implements AppRouteWrapper {<br>  const CounterView({super.key});<br><br>  @override<br>  Widget wrappedRoute(BuildContext context) {<br>    return BlocProvider(create: (_) =&gt; CounterCubit());<br>  }<br><br>  @override<br>  Widget build(BuildContext context) {<br>    return ...;<br>  }</pre><p>With this approach, we completely avoid cluttering our router definition with provider boilerplate. The routing system detects the wrapper protocol and automatically injects the provider behind the scenes.</p><h4>Wiring Custom Observer</h4><p>To monitor navigation events, log route transitions, or integrate with analytics tools (like <strong>Firebase Analytics</strong> or <strong>Sentry</strong>), our routing system supports custom navigation observers.</p><p>Here is how you can implement a simple custom observer to track screen pushes, pops, and replacements:</p><pre>import &#39;package:flutter/material.dart&#39;;<br><br>final class AppNavigationObserver extends NavigatorObserver {<br>  @override<br>  void didPush(Route&lt;dynamic&gt; route, Route&lt;dynamic&gt;? previousRoute) {<br>    super.didPush(route, previousRoute);<br>    debugPrint(&#39;🚀 Navigated TO: ${route.settings.name} (from: ${previousRoute?.settings.name})&#39;);<br>  }<br><br>  @override<br>  void didPop(Route route, Route? previousRoute) {<br>    super.didPop(route, previousRoute);<br>    debugPrint(&#39;🔙 Popped FROM: ${route.settings.name} (returned to: ${previousRoute?.settings.name})&#39;);<br>  }<br><br>  @override<br>  void didReplace({Route? newRoute, Route? oldRoute}) {<br>    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);<br>    debugPrint(&#39;🔄 Replaced: ${oldRoute?.settings.name} WITH ${newRoute?.settings.name}&#39;);<br>  }<br>}</pre><p>Once implemented, you can pass your custom observer directly into your router configuration during app initialization:</p><pre>RouterConfig&lt;Object&gt; initRouterConfig() {<br>  final routerConfig = AppGoRouterConfig(<br>    initialLocation: AuthRoutesInfo.login,<br>    modules: [<br>      AuthModuleRouter(),<br>      HomeModuleRouter(<br>        postViewCompositionApi: getIt(), <br>        settingViewCompositionApi: getIt(),<br>      ),<br>      PostsModuleRouter(),<br>    ],<br>    guards: [],<br>    observers: [AppNavigationObserver()], // here<br>    debugLogDiagnostics: kDebugMode,<br>  );<br><br>  getIt.registerSingleton&lt;AppRouterConfig&gt;(routerConfig);<br>  getIt.registerSingleton&lt;AppNavigationService&gt;(GoRouterNavigationService(getIt&lt;AppRouterConfig&gt;().config));<br>  return routerConfig.config;<br>}</pre><h4>Securing Routes with Navigation Guards</h4><p>In any production-grade application, certain screens require authentication or specific permissions before access is granted. To handle this cleanly, our routing system supports <strong>Navigation Guards</strong> using a straightforward redirect-based protocol.</p><p>Here is how you implement an <strong>AppRouteGuard</strong>. The <strong>redirect</strong> method evaluates a condition and returns either null (allowing navigation to proceed) or an <strong>AppRouteRequest</strong> to redirect the user:</p><pre>final class AuthGuard implements AppRouteGuard {<br>  AuthGuard({required AuthService authService}) : _authService = authService;<br><br>  final AuthService _authService;<br><br>  @override<br>  FutureOr&lt;AppRouteRequest?&gt; redirect(BuildContext context, AppRouteArguments args) async {<br>    // Check if the user is authenticated<br>    final isAuthenticated = await _authService.checkIsAuthenticated();<br>    <br>    // If not authenticated, redirect to login; otherwise, allow access (return null)<br>    if (!isAuthenticated) {<br>      return AppRoutes.auth.login();<br>    }<br>    <br>    return null;<br>  }<br>}</pre><p>So, we can register it in the configuration file:</p><pre>RouterConfig&lt;Object&gt; initRouterConfig() {<br>  final routerConfig = AppGoRouterConfig(<br>    initialLocation: AuthRoutesInfo.login,<br>    modules: [<br>      AuthModuleRouter(),<br>      HomeModuleRouter(postViewCompositionApi: getIt(), settingViewCompositionApi: getIt()),<br>      PostsModuleRouter(),<br>    ],<br>    guards: [AuthGuard(authService: getIt())], // here<br>    observers: [AppNavigationObserver()],<br>    debugLogDiagnostics: kDebugMode,<br>  );<br><br>  getIt.registerSingleton&lt;AppRouterConfig&gt;(routerConfig);<br><br>  getIt.registerSingleton&lt;AppNavigationService&gt;(GoRouterNavigationService(getIt&lt;AppRouterConfig&gt;().config));<br><br>  return routerConfig.config;<br>}</pre><h3>Conclusion</h3><p>By moving away from monolithic route files and embracing a modular, type-safe architecture, you can tame even the most complex navigation requirements in Flutter. With decentralized module routers, clean composition APIs, automatic provider wrapping, global observers, navigation guards, and responsive presentation modes, your routing layer becomes robust, testable, and completely decoupled.</p><p>This setup not only keeps your codebase clean as your team and project scale, but it also delivers a seamless, adaptive experience across mobile, tablet, and desktop platforms. Happy coding!</p><p><a href="https://github.com/thisisyusub/scalable-routing-system-demo">GitHub - thisisyusub/scalable-routing-system-demo</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=73510bf3d602" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/make-scalable-routing-system-for-your-flutter-apps-73510bf3d602">Make scalable routing system for your Flutter Apps</a> was originally published in <a href="https://medium.com/flutter-community">Flutter Community</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>