<?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>Fri, 13 Nov 2026 15:30:16 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[Direct Native Interop in Flutter 3.50: Let’s understand it! ]]></title>
            <link>https://medium.com/flutter-community/native-interop-6aef2eae745e?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/6aef2eae745e</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[flutter-app-development]]></category>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <dc:creator><![CDATA[Abhishek Doshi]]></dc:creator>
            <pubDate>Thu, 12 Nov 2026 18:47:17 GMT</pubDate>
            <atom:updated>2026-11-12T18:47:16.045Z</atom:updated>
            <content:encoded><![CDATA[<h4>It’s time to say goodbye to platform channel boilerplate and call native code directly from Dart!</h4><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*U4HyVheTwhVnsxaejD6WMg.png" /></figure><p>Flutter 3.50 is here, and one feature I’m personally very excited about is <strong>direct native interop going stable</strong>!</p><p>If you have ever written a platform channel, you know the drill. A channel name string in Dart. The same string again in Kotlin. The same again in Swift. And if any one of them doesn’t match, you find out at runtime with a MissingPluginException. We all have been there 😅</p><p>With Flutter 3.50, that changes. Let’s understand what it is and how to use it!</p><h3>What is Direct Native Interop?</h3><p>Until now, the standard way to talk to Android or iOS from Flutter was <strong>platform channels</strong>. Your Dart code sends a message, it gets serialized, it reaches the native side, native code does the work, and the reply comes back the same way. Every call is async and pays this cost.</p><p><strong>Direct native interop</strong> means your Dart code calls Java, Kotlin, or Objective-C APIs directly, using type-safe Dart bindings generated for you.</p><p>In simple words, calling a native API becomes just like calling a normal Dart function. No channel names, no MethodCall switch, no Future for something that should be a simple function call.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*LJhb6A4J70PnXMSf0IY22w.gif" /></figure><h3>What’s new in Flutter 3.50?</h3><ul><li><a href="https://docs.flutter.dev/interop/jnigen"><strong>JNIgen</strong></a> (for Java and Kotlin) is now production-ready.</li><li><a href="https://docs.flutter.dev/interop/ffigen"><strong>FFIgen</strong></a> (for Objective-C) is now production-ready. Swift works too, as long as your Swift code is exposed to Objective-C using @objc.</li><li>A <strong>Pigeon Native Interop (Preview)</strong> that generates type-safe bindings on top of both.</li><li>You can call native code <strong>synchronously</strong>, and even from <strong>background isolates</strong>.</li></ul><p>PS: This was possible because of the <strong>thread merge</strong> that started in Flutter 3.29. Dart code now runs on the platform’s main thread on Android and iOS (and later on desktop too), so there is no wall between Dart and native anymore.</p><h3>How to use it?</h3><p>Let’s take the classic “show a toast” example.</p><p><strong>Earlier, with a platform channel</strong>, you had to write Dart:</p><pre>const channel = MethodChannel(&#39;com.example/toast&#39;);<br>Future&lt;void&gt; showToast(String message) async {<br>  await channel.invokeMethod(&#39;showToast&#39;, {&#39;message&#39;: message});<br>}</pre><p>And Kotlin in MainActivity:</p><pre>MethodChannel(<br>    flutterEngine.dartExecutor.binaryMessenger,<br>    &quot;com.example/toast&quot;,<br>).setMethodCallHandler { call, result -&gt;<br>    if (call.method == &quot;showToast&quot;) {<br>        val message = call.argument&lt;String&gt;(&quot;message&quot;)<br>        Toast.makeText(this, message, Toast.LENGTH_LONG).show()<br>        result.success(null)<br>    } else {<br>        result.notImplemented()<br>    }<br>}</pre><p><strong>Now, with JNIgen</strong>, you just need 3 steps:</p><p><strong>Step 1:</strong> Add the packages:</p><pre>flutter pub add jni jni_flutter<br>flutter pub add dev:jnigen</pre><p>As of today (10th November 2026), the latest versions are jni 1.1.2, jni_flutter 1.0.4 and jnigen 1.0.1.</p><p><strong>Step 2:</strong> Create a config file, tool/jnigen.dart:</p><pre>import &#39;dart:io&#39;;<br>import &#39;package:jnigen/jnigen.dart&#39;;<br>void main(List&lt;String&gt; args) async {<br>  final packageRoot = Platform.script.resolve(&#39;../&#39;);<br>  final generator = JniGenerator(<br>    input: Input(<br>      classes: [&#39;android.widget.Toast&#39;, &#39;java.lang.CharSequence&#39;],<br>      androidSdk: AndroidSdk(<br>        addGradleDeps: true,<br>        androidExample: packageRoot,<br>      ),<br>    ),<br>    output: Output(<br>      dart: DartOutput(<br>        path: packageRoot.resolve(&#39;lib/android_utils.g.dart&#39;),<br>        structure: OutputStructure.singleFile,<br>      ),<br>    ),<br>  );<br>  await generator.generate();<br>}</pre><p><strong>Step 3:</strong> Run dart run tool/jnigen.dart and call Android directly from Dart:</p><pre>import &#39;package:jni/jni.dart&#39;;<br>import &#39;package:jni_flutter/jni_flutter.dart&#39;;<br>import &#39;android_utils.g.dart&#39;;<br>void showToast(String message) {<br>  Toast.makeText$1(<br>    androidApplicationContext,<br>    message.toJString().as(CharSequence.type),<br>    Toast.LENGTH_LONG,<br>  )!.show();<br>}</pre><p>That’s it! No Kotlin file, no channel. And notice that it returns void, not Future&lt;void&gt;. It&#39;s a direct call!</p><p>The $1 suffix is how JNIgen handles Java method overloads, since Dart doesn&#39;t have overloading. Always check the generated file to make sure you picked the right one.</p><p>On iOS, it’s the same idea with FFIgen. You generate bindings from the Objective-C headers (for example, Apple’s EventKit) and use them like this:</p><pre>final store = EKEventStore();<br>final start = NSDate.date();<br>final end = NSDate.dateWithTimeIntervalSinceNow(30.0 * 24 * 60 * 60);<br>final predicate = store.predicateForEventsWithStartDate(<br>  start,<br>  endDate: end,<br>);<br>final events = store.eventsMatchingPredicate(predicate);</pre><p>You can find complete examples in the <a href="https://github.com/flutter/demos/tree/main/native_interop_demos">official Flutter native interop demos</a> (android_toast_demo and ios_calendar_demo).</p><h3>Already using Pigeon?</h3><p>You can try the new <strong>Native Interop (Preview)</strong> by turning it on in your Pigeon file. Pigeon then runs JNIgen and FFIgen for you:</p><pre>@ConfigurePigeon(<br>  PigeonOptions(<br>    kotlinOptions: KotlinOptions(useJni: true),<br>    swiftOptions: SwiftOptions(useFfi: true, ffiModuleName: &#39;Runner&#39;),<br>  ),<br>)</pre><p>Keep in mind that it is still a preview: it supports Android, iOS and macOS only (no Windows or Linux), and @TaskQueue is not supported. Check out the <a href="https://github.com/flutter/packages/blob/main/packages/pigeon/native_interop_guide.md">Pigeon Native Interop guide</a> for the full setup.</p><h3>Advantages of Direct Native Interop</h3><ul><li><strong>Type safety:</strong> No more string-based method names. If something is wrong, the compiler tells you.</li><li><strong>Synchronous calls:</strong> Get results directly, without await.</li><li><strong>Better performance:</strong> No serialization and no thread hops. Great for frequent calls or large data.</li><li><strong>Less code:</strong> You can use Android SDK or Apple framework APIs without writing a native wrapper.</li></ul><h3>Things to keep in mind</h3><p>Direct interop takes you closer to native, so there are a few rules to follow:</p><ul><li><strong>Memory:</strong> On Android, every Java object you hold in Dart uses a JNI global reference, and there is a limit of around 50,000. Call .release() in dispose() for long-lived objects. On iOS, use WeakReference to avoid reference cycles.</li><li><strong>Threads:</strong> Synchronous doesn’t mean free. Slow calls on the main thread will still drop frames, so move heavy work to a background isolate. Also, UI-related Objective-C calls must run on the main thread.</li><li><strong>Type checks:</strong> Dart’s is and as don&#39;t check the native runtime type. Use .isA(Foo.type) and .as(Foo.type) instead.</li><li><strong>R8 / ProGuard:</strong> Release builds can strip classes that are only used from Dart. Add @Keep to your own Kotlin classes and methods.</li><li><strong>Build first:</strong> JNIgen needs your Android app to be built at least once before generating bindings.</li><li><strong>Swift:</strong> FFIgen talks to Swift through Objective-C, so classes need @objc and should extend NSObject. Pure Swift interop (swiftgen) is still experimental.</li></ul><h3>Should I migrate my platform channels now?</h3><p>No need to rush! If your platform channel code works well and is tested, it’s not suddenly bad code. Please don’t feel pressured to rewrite it.</p><p>Use direct interop for <strong>new</strong> native integrations, or for calls where you really need sync results or better performance. If you maintain a plugin, a planned migration in a future major version is totally fine. Your users will thank you for stability.</p><p>Also, a big thank you to the Dart and Flutter teams and to everyone in the community who tested this early and reported issues. That’s the reason it’s stable today 💙</p><p>Hope you enjoyed this article!</p><p>Doubts? Feel free to drop a message <a href="https://linktr.ee/abhishekdoshi26">@AbhishekDoshi26</a><br>Checkout <a href="https://abhishekdoshi.dev/">abhishekdoshi.dev</a> for more info 💙</p><blockquote>Don’t stop, until you are breathing!💙<br>- Abhishek Doshi</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6aef2eae745e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/native-interop-6aef2eae745e">Direct Native Interop in Flutter 3.50: Let’s understand it! 💙</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[How Flutter’s Material and Cupertino Decoupling is Changing Cross-Platform UI Development]]></title>
            <link>https://medium.com/flutter-community/how-flutters-material-and-cupertino-decoupling-is-changing-cross-platform-ui-development-da944fbb9977?source=rss----86fb29d7cc6a---4</link>
            <guid isPermaLink="false">https://medium.com/p/da944fbb9977</guid>
            <category><![CDATA[flutter-app-development]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[flutter-ui]]></category>
            <dc:creator><![CDATA[Ella Wilson]]></dc:creator>
            <pubDate>Sat, 07 Nov 2026 21:48:16 GMT</pubDate>
            <atom:updated>2026-11-07T21:48:15.190Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*oiVtftuRRk5YVcOiVEEW_A.png" /></figure><p>Flutter has long enabled developers to build Android and iOS applications from a shared codebase while supporting platform-specific interfaces through Material and Cupertino widgets. However, because these design libraries were bundled with the Flutter SDK, even minor framework-provided UI improvements could require a complete SDK upgrade, potentially introducing unrelated changes and additional regression testing.</p><p>In August 2026, Flutter 3.47 addressed this limitation by introducing independently versioned material_ui and cupertino_ui packages.<a href="https://flutter.dev/blog/decoupling-material-cupertino"> Officially announced in September 2026</a>, this architectural change allows Flutter’s design libraries to evolve separately from the core framework, with implications for platform-specific UI updates, custom design systems, and long-term application maintenance.</p><h3>How Decoupling Changes Cross-Platform UI Development</h3><p>For developers, the change affects more than package updates. It also influences how platform-specific interfaces evolve, how custom design systems are built, and how UI dependencies are managed across applications.</p><h4>1. Faster Adoption of Platform-Specific UI Changes</h4><p>Google’s Material 3 Expressive and Apple’s Liquid Glass introduce new visual treatments and interaction patterns. Flutter’s standalone design libraries let teams implement these evolving design systems without waiting for the next Flutter SDK update.</p><p>Applications maintaining separate Android and iOS interfaces can incorporate supported design updates through the relevant package, subject to SDK and dependency compatibility. This gives teams greater control over when to adopt new platform conventions without requiring coordinated changes to both UI libraries.</p><p>However, decoupling does not automatically deliver native UI behavior. Material and Cupertino remain Flutter-rendered widget libraries, and developers must still implement appropriate platform conventions and validate accessibility, animations, and interactions.</p><h4>2. Better Foundations for Custom Design Systems</h4><p>Flutter’s lower-level widgets library historically lacked certain reusable UI capabilities, forcing custom design system developers to recreate components or rely on design-specific implementations.</p><p>Alongside decoupling, the Flutter team is addressing gaps in its style-neutral widgets library to provide more reusable building blocks. As more foundational functionality becomes available, developers may be able to reuse common UI behavior without depending on either design library.</p><p><strong>Note:</strong> These improvements remain ongoing and are not automatically available through the standalone packages.</p><h4>3. More Explicit UI Dependency Boundaries</h4><p>Independent design libraries make it more important to identify where reusable components depend on Material or Cupertino APIs. Shared widget packages that expose design-specific classes through public interfaces can become difficult to maintain when applications and third-party dependencies use incompatible library implementations.</p><p>As Material and Cupertino evolve independently, these dependencies become more visible, reinforcing the importance of clear boundaries between shared UI components and platform-specific implementations. This separation can help contain the impact of future design-library changes on the wider application architecture.</p><h3>What Should Developers Consider Before Migrating to Standalone Packages?</h3><p>Before migrating to standalone Material and Cupertino packages, Flutter app development teams should evaluate their application’s dependencies, UI architecture, and compatibility requirements. The following checks can help reduce migration risks:</p><h4><strong>1. Audit Third-Party UI Dependencies</strong></h4><p>Identify packages that expose Material or Cupertino types through public APIs. Dart treats identically named classes from the bundled and standalone libraries as distinct types because they have different library identities. This can cause compilation errors when third-party APIs expect legacy types. Check whether critical dependencies support the standalone packages, and consider deferring migration where compatible versions are unavailable.</p><h4><strong>2. Assess Shared Widgets and Design-system Dependencies</strong></h4><p>Review reusable components, theme configurations, and internal UI packages for direct references to legacy Material or Cupertino types. Coordinate updates across shared libraries to prevent incompatible interfaces.</p><h4><strong>3. Plan for Mixed-library Compatibility</strong></h4><p>Where legacy and standalone widgets must coexist, evaluate Flutter’s compatibility bridges for inherited theme and localization context. These bridges cannot resolve Dart type mismatches in public APIs, which require compatible package implementations.</p><h4><strong>4. Update Localization and Validate UI Behavior</strong></h4><p>Review localization delegates and test navigation, theming, accessibility, animations, and platform-specific interactions after migration. Include widget, integration, and visual regression tests where applicable.</p><p>For applications with compatible dependencies, developers can use Flutter’s automated migration command, dart fix — apply — code=migrate_design_widgets, and ensure the standalone packages are added to the project’s dependencies.</p><p>For larger applications, developers should first migrate compatible internal packages and verify whether third-party dependencies expose legacy design-library types. Where mixed-library usage is unavoidable, proceed with migration only across tested compatibility boundaries. Applications dependent on incompatible public APIs may need to defer adoption until those packages are updated.</p><p>Refer to the<a href="https://docs.flutter.dev/release/breaking-changes/material-ui-and-cupertino-ui"> official Flutter migration guide</a> for implementation instructions.</p><h3>Ending Note: A More Modular Future for Flutter UI Development</h3><p>Flutter’s Material and Cupertino decoupling represents a shift toward independently maintained design systems and more flexible cross-platform architectures. While this creates opportunities for faster UI updates and greater customization, it also introduces new considerations around dependency compatibility and long-term maintenance.</p><p>For businesses, the immediate priority is to evaluate whether their existing applications are ready for this transition and where architectural changes may be necessary. Whether the migration is handled internally or with support from<a href="https://www.suntecindia.com/hire-flutter-developers.html"> experienced Flutter developers</a>, the focus should remain on maintaining application stability while preparing for Flutter’s evolving design-system architecture.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=da944fbb9977" width="1" height="1" alt=""><hr><p><a href="https://medium.com/flutter-community/how-flutters-material-and-cupertino-decoupling-is-changing-cross-platform-ui-development-da944fbb9977">How Flutter’s Material and Cupertino Decoupling is Changing Cross-Platform UI Development</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[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>
    </channel>
</rss>