<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Khalit Hartmann</title>
    <description>The latest articles on DEV Community by Khalit Hartmann (@khalit_hartmann_17e573503).</description>
    <link>https://dev.to/khalit_hartmann_17e573503</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012301%2F08d1c54e-6d1a-4f46-b404-68b001b98ae9.png</url>
      <title>DEV Community: Khalit Hartmann</title>
      <link>https://dev.to/khalit_hartmann_17e573503</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/khalit_hartmann_17e573503"/>
    <language>en</language>
    <item>
      <title>Klaviyo Push Notifications in Flutter: 3 Silent Pitfalls</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Fri, 17 Jul 2026 19:18:41 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/klaviyo-push-notifications-in-flutter-3-silent-pitfalls-p4a</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/klaviyo-push-notifications-in-flutter-3-silent-pitfalls-p4a</guid>
      <description>&lt;p&gt;&lt;em&gt;For CTOs, tech leads, and senior developers debugging why Klaviyo push notifications stopped working in their Flutter app — or about to find out why they will.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Klaviyo × Flutter series&lt;/strong&gt; (part 3 of 4): &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;planning &amp;amp; scope&lt;/a&gt; · &lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;the analytics layer&lt;/a&gt; · push notification pitfalls · &lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;profiles &amp;amp; newsletter&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The Klaviyo Flutter SDK's push setup looks like a 10-minute job — until you add it to an app that already uses &lt;code&gt;firebase_messaging&lt;/code&gt;. Three silent bugs surfaced in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silently dropped pushes:&lt;/strong&gt; Multiple Android services competing for the &lt;code&gt;MESSAGING_EVENT&lt;/code&gt; intent caused Klaviyo pushes to vanish without a trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken tap handling:&lt;/strong&gt; On both platforms, Klaviyo's tap handler broke &lt;code&gt;firebase_messaging&lt;/code&gt;'s &lt;code&gt;onMessageOpenedApp&lt;/code&gt; callback — deep links stopped working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible marketing pushes:&lt;/strong&gt; Android's write-once channel importance meant marketing notifications displayed silently instead of as heads-up banners.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The 10-minute install and the 3-day debug
&lt;/h2&gt;

&lt;p&gt;The Klaviyo Flutter SDK's push notification setup documentation reads like a checklist: add the &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; package, pass the FCM token, register for remote notifications on iOS. If you follow it from scratch in a new project, it works. The problems appear when you add Klaviyo to an app that already uses &lt;code&gt;firebase_messaging&lt;/code&gt; — which is every Flutter app that already sends push notifications.&lt;/p&gt;

&lt;p&gt;The collision is not a Klaviyo bug. It is a consequence of how Android's intent system and iOS's notification delegate chain work when multiple frameworks compete for the same system callbacks. The SDK documentation does not warn you because the SDK works correctly in isolation. The failures are integration-level, not SDK-level, and they produce no error logs, no crashes, no warnings. Pushes just stop arriving, taps stop responding, or banners stop popping up — and you have nothing in the console to search for.&lt;/p&gt;

&lt;p&gt;This post covers three pitfalls I hit during a production Klaviyo integration into a Flutter e-commerce app. The &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;overview post&lt;/a&gt; maps the full integration scope; this one zooms into the push layer. Each pitfall took longer to diagnose than to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Your Klaviyo pushes are being silently dropped
&lt;/h2&gt;

&lt;p&gt;On Android, push notifications arrive through Firebase Cloud Messaging. FCM delivers each incoming message to a service registered with the &lt;code&gt;com.google.firebase.MESSAGING_EVENT&lt;/code&gt; intent filter. The critical detail: FCM dispatches to &lt;strong&gt;exactly one&lt;/strong&gt; service. If multiple services declare the same intent filter, Android picks one based on service resolution order — and the others receive nothing.&lt;/p&gt;

&lt;p&gt;After adding the Klaviyo SDK, the merged Android manifest contained three competing services:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FlutterFirebaseMessagingService&lt;/code&gt;&lt;/strong&gt; — registered by the &lt;code&gt;firebase_messaging&lt;/code&gt; plugin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;KlaviyoPushService&lt;/code&gt;&lt;/strong&gt; — registered by the Klaviyo SDK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A custom service&lt;/strong&gt; — the app's own messaging service&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;FCM picked one. The others were silently excluded. No exception, no log entry. Messages dispatched to the wrong service simply vanished. If &lt;code&gt;FlutterFirebaseMessagingService&lt;/code&gt; won, Klaviyo pushes were dropped. If &lt;code&gt;KlaviyoPushService&lt;/code&gt; won, the app's own push handling broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: a single unified service
&lt;/h3&gt;

&lt;p&gt;The solution is a single custom service that extends &lt;code&gt;FlutterFirebaseMessagingService&lt;/code&gt; and delegates Klaviyo messages based on the &lt;code&gt;_k&lt;/code&gt; payload marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppPushService.kt&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.app&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;android.util.Log&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.google.firebase.messaging.RemoteMessage&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.klaviyo.pushFcm.KlaviyoNotification&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppPushService&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FlutterFirebaseMessagingService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onMessageReceived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RemoteMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"_k"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;KlaviyoNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;displayNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;applicationContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TAG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"KlaviyoNotification.displayNotification failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onMessageReceived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;TAG&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AppPushService"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extending &lt;code&gt;FlutterFirebaseMessagingService&lt;/code&gt; ensures that &lt;code&gt;firebase_messaging&lt;/code&gt;'s Dart-side callbacks (&lt;code&gt;onMessage&lt;/code&gt;, &lt;code&gt;onMessageOpenedApp&lt;/code&gt;) keep working for non-Klaviyo pushes via the &lt;code&gt;super.onMessageReceived(message)&lt;/code&gt; call. The &lt;code&gt;_k&lt;/code&gt; check gates only the Klaviyo rendering. Every message still flows through the Flutter plugin's handler.&lt;/p&gt;

&lt;p&gt;The second half of the fix is removing the competing services from the merged manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".AppPushService"&lt;/span&gt; &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.firebase.MESSAGING_EVENT"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/service&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"&lt;/span&gt;
    &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.klaviyo.pushFcm.KlaviyoPushService"&lt;/span&gt;
    &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tools:node="remove"&lt;/code&gt; directive strips both plugin-registered services from the final merged manifest. Only &lt;code&gt;AppPushService&lt;/code&gt; remains, and FCM has exactly one target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: Push taps stop working on both platforms
&lt;/h2&gt;

&lt;p&gt;After fixing delivery, a second problem appeared: tapping a notification stopped doing anything useful.&lt;/p&gt;

&lt;p&gt;Two things broke independently on the two platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Android&lt;/strong&gt;, Klaviyo's SDK builds its own &lt;code&gt;PendingIntent&lt;/code&gt; for the notification it displays. That intent does not carry the FCM extras that &lt;code&gt;firebase_messaging&lt;/code&gt; needs to populate the &lt;code&gt;RemoteMessage&lt;/code&gt; object in &lt;code&gt;onMessageOpenedApp&lt;/code&gt;. When the user taps a Klaviyo notification, the app opens, but &lt;code&gt;firebase_messaging&lt;/code&gt;'s tap callback receives an empty message — or never fires at all. The Dart-side &lt;code&gt;onMessageOpenedApp&lt;/code&gt; handler that normally routes deep links gets nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On iOS&lt;/strong&gt;, the problem was in the &lt;code&gt;UNUserNotificationCenter&lt;/code&gt; delegate chain. The initial implementation forwarded every notification tap to Klaviyo's handler without calling &lt;code&gt;super&lt;/code&gt;. That meant &lt;code&gt;firebase_messaging&lt;/code&gt;'s plugin — which also hooks into the same delegate method — never saw non-Klaviyo taps. The app's own push-based navigation stopped working entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;_k&lt;/code&gt; marker and the iOS trap
&lt;/h3&gt;

&lt;p&gt;The fix is gating on Klaviyo's &lt;code&gt;_k&lt;/code&gt; marker on both platforms, and always calling &lt;code&gt;super&lt;/code&gt; so &lt;code&gt;firebase_messaging&lt;/code&gt; keeps working for non-Klaviyo pushes.&lt;/p&gt;

&lt;p&gt;There is a subtle platform difference here that cost debugging time: on Android, &lt;code&gt;_k&lt;/code&gt; sits &lt;strong&gt;top-level&lt;/strong&gt; in the FCM data payload. On iOS, it is &lt;strong&gt;nested under the &lt;code&gt;body&lt;/code&gt; key&lt;/strong&gt; of the APNs payload. A naive check for &lt;code&gt;userInfo["_k"]&lt;/code&gt; on iOS will miss it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;userNotificationCenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UNUserNotificationCenter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;didReceive&lt;/span&gt; &lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UNNotificationResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;withCompletionHandler&lt;/span&gt; &lt;span class="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;userInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isKlaviyoPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;AnyHashable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])?[&lt;/span&gt;&lt;span class="s"&gt;"_k"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"_k"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;isKlaviyoPush&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;KlaviyoFlutterSdkPlugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleNotificationResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;userNotificationCenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;didReceive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;withCompletionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;completionHandler&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double check — &lt;code&gt;body._k&lt;/code&gt; first, then top-level &lt;code&gt;_k&lt;/code&gt; as a fallback — handles both the current APNs payload structure and guards against future changes.&lt;/p&gt;

&lt;p&gt;The critical line is &lt;code&gt;super.userNotificationCenter(...)&lt;/code&gt;. Without it, &lt;code&gt;firebase_messaging&lt;/code&gt; never receives the tap event, and &lt;code&gt;onMessageOpenedApp&lt;/code&gt; on the Dart side stays silent for non-Klaviyo pushes. Always call &lt;code&gt;super&lt;/code&gt;, regardless of whether the push is from Klaviyo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dart-side handling: skip Klaviyo taps
&lt;/h3&gt;

&lt;p&gt;On the Dart side, the &lt;code&gt;onMessageOpenedApp&lt;/code&gt; handler should skip messages that carry the &lt;code&gt;_k&lt;/code&gt; marker. The Klaviyo native SDK already tracks the open event when the user taps a Klaviyo notification — if the Dart handler also processes it, you get double handling: Klaviyo registers the open, and your app also tries to route a deep link that does not exist in the Klaviyo payload.&lt;/p&gt;

&lt;p&gt;The gate is straightforward: check for &lt;code&gt;_k&lt;/code&gt; in &lt;code&gt;message.data&lt;/code&gt; on Android, skip if present. On iOS the Klaviyo tap is already handled natively and typically does not bubble to &lt;code&gt;onMessageOpenedApp&lt;/code&gt; at all if the delegate chain is set up correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Android channel importance is write-once
&lt;/h2&gt;

&lt;p&gt;This one is the quietest failure mode. Klaviyo pushes arrive. Taps work. But the notifications show up silently in the notification shade instead of popping up as heads-up banners. Marketing pushes that nobody sees are marketing pushes that do not convert — and in a DTC e-commerce app, push notifications are a &lt;a href="https://khal.it/blog/smart-push-notifications-jitai-framework" rel="noopener noreferrer"&gt;direct revenue channel&lt;/a&gt;. Silent notifications mean lost sales, not just a UX inconvenience.&lt;/p&gt;

&lt;p&gt;The cause is an Android platform behavior: &lt;strong&gt;notification channel importance is immutable after creation.&lt;/strong&gt; Once a channel is registered with &lt;code&gt;Importance.DEFAULT&lt;/code&gt;, no code can raise it to &lt;code&gt;Importance.HIGH&lt;/code&gt;. The channel ID is the key — same ID, same importance, forever, until the user manually changes it in system settings or the app deletes and recreates the channel with a new ID.&lt;/p&gt;

&lt;p&gt;If the app's notification channels were originally created at default importance — which is common, since default importance is the... default — then switching to Klaviyo for marketing pushes does not magically upgrade those channels. The push arrives, lands in the existing channel, and displays silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: bump the channel ID
&lt;/h3&gt;

&lt;p&gt;The only reliable fix is to create new channels with new IDs at the desired importance level, and delete the legacy channels on startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;NotificationChannelId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;marketing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'marketing_v2'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'content_v2'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;loyalty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'loyalty_v2'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NotificationChannelId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;_v2&lt;/code&gt; suffix is a convention, not a requirement — any new string works. The point is that it is a &lt;strong&gt;different channel ID&lt;/strong&gt;, so Android creates a fresh channel with the importance level you specify at creation time. On app startup, delete the legacy channel IDs so users do not see duplicate entries in their notification settings.&lt;/p&gt;

&lt;p&gt;This generalizes beyond importance: any channel-level property change in Android — sound, vibration pattern, LED color — requires an ID bump. Android's channel system is designed to give users control after the channel is created. The app gets one shot at the defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your estimate
&lt;/h2&gt;

&lt;p&gt;If someone quotes you a day for "adding Klaviyo push notifications to the Flutter app," they are quoting the SDK installation. The SDK installation is real — it takes an hour or two. But if the app already has &lt;code&gt;firebase_messaging&lt;/code&gt;, the three pitfalls above are near-certain, and each one takes longer to diagnose than to fix.&lt;/p&gt;

&lt;p&gt;A realistic estimate for push notification integration in a Flutter app that already uses &lt;code&gt;firebase_messaging&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery routing (Pitfall 1):&lt;/strong&gt; 0.5–1 day. The fix is small; diagnosing silent drops is the time sink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tap collision (Pitfall 2):&lt;/strong&gt; 0.5–1 day. Two platforms, two different &lt;code&gt;_k&lt;/code&gt; locations, both need testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel importance migration (Pitfall 3):&lt;/strong&gt; 0.25–0.5 day. Straightforward once diagnosed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Notification Service Extension (if rich push is required):&lt;/strong&gt; 0.5–1 day. Klaviyo's rich push (images, GIFs, action buttons) requires a separate Xcode target that the Flutter toolchain does not scaffold. The extension code itself is minimal — &lt;code&gt;KlaviyoSwiftExtension&lt;/code&gt; handles media downloads — but the Xcode configuration, provisioning profile, and app group setup are all manual steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total: &lt;strong&gt;2–3.5 days&lt;/strong&gt; for push alone, not counting QA across both platforms with real devices. End-to-end push delivery through Klaviyo's infrastructure requires physical devices — the iOS simulator supports basic push simulation (drag-and-drop APNs payloads since Xcode 11.4), but the full Klaviyo routing chain only works on real hardware. Budget for device builds on both platforms.&lt;/p&gt;

&lt;p&gt;If you are evaluating a Klaviyo integration as part of a &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;larger app development project&lt;/a&gt;, the push layer is just one of four workstreams — &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;the overview post&lt;/a&gt; maps the full scope.&lt;/p&gt;

&lt;p&gt;Planning a Klaviyo integration for your Flutter app? I've shipped this in production and documented the hours — &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;book a 20-minute coffee chat&lt;/a&gt; and bring your tech stack. I'll tell you where the days will go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern across all three pitfalls
&lt;/h2&gt;

&lt;p&gt;The failure is silent, the diagnosis takes longer than the fix, and the root cause is a platform behavior — not a Klaviyo bug. Here is what that means in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silent failures are the worst failures.&lt;/strong&gt; All three pitfalls produce zero log output. The push arrives at the device, gets dispatched to the wrong handler or the wrong channel, and disappears. Add logging at the native service entry point before you start debugging anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;_k&lt;/code&gt; marker is your routing key.&lt;/strong&gt; Every Klaviyo-specific code path — delivery, taps, Dart-side handling — should gate on this single marker. Memorize where it lives: top-level in Android data payloads, nested under &lt;code&gt;body&lt;/code&gt; in iOS APNs payloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tools:node="remove"&lt;/code&gt; is essential in multi-SDK Android apps.&lt;/strong&gt; When two plugins register services for the same intent filter, the merged manifest creates a race condition. Stripping the plugin-registered services and replacing them with a single custom service is the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel importance is a one-shot decision.&lt;/strong&gt; If you are setting up notification channels for the first time, think about importance carefully. If the channels already exist at default importance, the only path to heads-up banners is a new channel ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget platform debugging, not SDK installation.&lt;/strong&gt; The SDK works. The platform integration is where the days go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For debugging all three pitfalls, start with native-layer logging at the service entry point. On Android, add a &lt;code&gt;Log.d&lt;/code&gt; call at the top of &lt;code&gt;onMessageReceived&lt;/code&gt; in your custom service — if you never see the log line, the service is not receiving messages. On iOS, log in the &lt;code&gt;didReceive&lt;/code&gt; delegate method. These entry-point logs are the fastest way to determine which layer is swallowing the message.&lt;/p&gt;

&lt;p&gt;With push delivery, taps, and channels sorted, the remaining integration work was identity and newsletter — which introduced its own set of silent data-integrity bugs. &lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;Part 4 covers those traps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;Klaviyo Shopify Integration in Flutter: What to Know First&lt;/a&gt; — the full project scope and why push was the hardest workstream.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;One trackEvent(), Four SDKs: Analytics Layer for Flutter&lt;/a&gt; — the dispatcher architecture that the push event tracking flows through.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;Klaviyo Profiles &amp;amp; Newsletter: Where the Docs Won't Save You&lt;/a&gt; — the identity and newsletter traps that came after push was sorted.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/smart-push-notifications-jitai-framework" rel="noopener noreferrer"&gt;Smart Push Notifications: The JITAI Framework&lt;/a&gt; — designing push strategies that users actually open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I set up Klaviyo push notifications in Flutter?
&lt;/h3&gt;

&lt;p&gt;Install the &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; package, pass the FCM token to Klaviyo on app startup, and register for remote notifications on iOS. The SDK documentation covers the basic steps — the setup itself takes an hour or two. The real work starts when you hit the three integration pitfalls described above, which are near-certain if the app already uses &lt;code&gt;firebase_messaging&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I test Klaviyo push notifications in the iOS simulator?
&lt;/h3&gt;

&lt;p&gt;Partially. The iOS simulator supports basic push simulation via drag-and-drop APNs payloads (since Xcode 11.4), and Android emulators with Google Play Services can receive FCM messages. However, end-to-end delivery through Klaviyo's infrastructure — where Klaviyo sends the push via FCM/APNs — requires physical devices on both platforms. The three integration pitfalls described above all manifested during real-device testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the MESSAGING_EVENT conflict apply to other push SDKs besides Klaviyo?
&lt;/h3&gt;

&lt;p&gt;Yes. Any SDK that registers its own &lt;code&gt;FirebaseMessagingService&lt;/code&gt; in the Android manifest will create the same intent filter collision. The pattern — a single custom service that extends &lt;code&gt;FlutterFirebaseMessagingService&lt;/code&gt; and delegates based on payload markers — applies to any multi-SDK push setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to fix all three pitfalls, or can I skip some?
&lt;/h3&gt;

&lt;p&gt;Pitfall 1 (delivery routing) is mandatory — without it, either Klaviyo pushes or your existing pushes will be silently dropped. Pitfall 2 (tap collision) depends on whether your app uses &lt;code&gt;onMessageOpenedApp&lt;/code&gt; for deep linking. Pitfall 3 (channel importance) only matters if you need heads-up banners for marketing pushes, but marketing pushes that display silently have significantly lower engagement.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>mobile</category>
    </item>
    <item>
      <title>One trackEvent(), Four SDKs: Analytics Layer for Flutter</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Fri, 17 Jul 2026 12:59:12 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/one-trackevent-four-sdks-analytics-layer-for-flutter-io0</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/one-trackevent-four-sdks-analytics-layer-for-flutter-io0</guid>
      <description>&lt;p&gt;&lt;em&gt;For CTOs, tech leads, and senior developers managing analytics across multiple providers in a production mobile app — or about to add Klaviyo to an existing Firebase setup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Klaviyo × Flutter series&lt;/strong&gt; (part 2 of 4): &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;planning &amp;amp; scope&lt;/a&gt; · the analytics layer · &lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;push notification pitfalls&lt;/a&gt; · &lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;profiles &amp;amp; newsletter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Before adding Klaviyo to a Flutter e-commerce app, I built a unified analytics layer — an &lt;code&gt;AnalyticsDispatcher&lt;/code&gt; that fans one &lt;code&gt;trackEvent()&lt;/code&gt; call to every registered provider through per-provider adapters. The dispatcher handles consent gating and error isolation so one SDK crashing never blocks the others. Google Ads was added after Klaviyo with zero changes to any call site — the pattern paid for itself immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  The starting point: 10 call sites, 2 SDKs, no abstraction
&lt;/h2&gt;

&lt;p&gt;The app — a Flutter e-commerce app for a Shopify-based DTC jewelry brand — had grown its analytics organically. About 10 screens and controllers tracked events. Each one imported &lt;code&gt;FirebaseAnalytics&lt;/code&gt; directly, built a parameter map, and called &lt;code&gt;logEvent()&lt;/code&gt;. A second SDK — an attribution service — was wired in alongside it in some of those same call sites.&lt;/p&gt;

&lt;p&gt;No interface. No shared contract. Every call site had to know which SDKs existed and how to talk to each one.&lt;/p&gt;

&lt;p&gt;That worked with two providers and low churn. The moment the client asked for a &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;Klaviyo integration&lt;/a&gt;, the math changed. Adding Klaviyo to the existing model meant visiting every one of those ~10 call sites, adding a third direct call, and tripling the consent-checking surface. The next provider after that — Meta was already in the estimate — would do it again.&lt;/p&gt;

&lt;p&gt;Rough count before the refactoring: ~10 call sites, ~15 integration points (some files called both SDKs, some only Firebase), 0 shared abstractions. After: ~10 call sites, 1 integration point each, and a dispatcher that handles the fan-out.&lt;/p&gt;

&lt;p&gt;The analytics layer was the largest single work package of the entire Klaviyo project. It had to ship and stabilize before the Klaviyo adapter could plug in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: &lt;a href="https://refactoring.guru/design-patterns/adapter" rel="noopener noreferrer"&gt;Adapter&lt;/a&gt; + &lt;a href="https://refactoring.guru/design-patterns/mediator" rel="noopener noreferrer"&gt;Mediator&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The architecture has two moving parts: an interface that every provider implements, and a dispatcher that iterates over all registered providers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubefonf0ni3985g7dkjf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubefonf0ni3985g7dkjf.png" alt="Architecture diagram: Call sites send events to the AnalyticsDispatcher, which fans out to Firebase, Klaviyo, and Google Ads adapters" width="799" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The adapter interface
&lt;/h3&gt;

&lt;p&gt;Every analytics SDK — Firebase, Klaviyo, Google Ads, and a Meta stub — implements &lt;code&gt;IAnalyticsAdapter&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AnalyticsProviderId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;firebase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'firebase_template_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;klaviyo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'klaviyo_template_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;googleAds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'google_ads_template_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;AnalyticsProviderId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;ConsentResolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IAnalyticsAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;AnalyticsProviderId&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;providerId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;trackScreenView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;screenName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;screenClass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setUserProperty&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;applyConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConsentResolver&lt;/span&gt; &lt;span class="n"&gt;hasConsent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;setEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;templateId&lt;/code&gt; on each enum value is the key used by the consent management platform (Usercentrics in this project) to identify each data processor. More on that in the consent section below.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;applyConsent&lt;/code&gt; method has a default implementation: it resolves the provider's own template ID against the consent resolver and calls &lt;code&gt;setEnabled&lt;/code&gt;. Providers that need deeper consent integration — Firebase with Google Consent Mode v2, for example — override this method.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dispatcher
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;AnalyticsDispatcher&lt;/code&gt; receives events through a single API and fans them out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AnalyticsDispatcher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAnalyticsAdapter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_providers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ConsentResolver&lt;/span&gt; &lt;span class="n"&gt;_hasConsent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_checkConsentAndDispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;eventName:&lt;/span&gt; &lt;span class="n"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;parameters:&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nl"&gt;debugLabel:&lt;/span&gt; &lt;span class="s"&gt;'trackEvent(&lt;/span&gt;&lt;span class="si"&gt;$eventName&lt;/span&gt;&lt;span class="s"&gt;)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_checkConsentAndDispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAnalyticsAdapter&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;debugLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_providers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;providerId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;hasConsent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_hasConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;hasConsent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;developer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s"&gt;'Error in &lt;/span&gt;&lt;span class="si"&gt;$debugLabel&lt;/span&gt;&lt;span class="s"&gt; on &lt;/span&gt;&lt;span class="si"&gt;$name&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'AnalyticsDispatcher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;error:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen in &lt;code&gt;_checkConsentAndDispatch&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Consent gating.&lt;/strong&gt; Each provider's template ID is checked against the user's consent state. No consent, no call. The provider never even sees the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error isolation.&lt;/strong&gt; Each provider call is wrapped in its own try/catch. If Klaviyo's SDK throws, Firebase and Google Ads still receive the event. Failures are logged but never propagated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug labeling.&lt;/strong&gt; The &lt;code&gt;debugLabel&lt;/code&gt; parameter produces readable log messages like &lt;code&gt;Error in trackEvent(view_item) on klaviyo_template_id: ...&lt;/code&gt; — which makes production debugging viable without a debugger attached.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every call site in the app now depends on a single type: the dispatcher. Screens and controllers call &lt;code&gt;dispatcher.trackEvent(...)&lt;/code&gt; and never import a provider SDK directly. The Firebase Analytics integration, the Klaviyo adapter, attribution — all invisible to the calling code. That single interface also means every call site can be tested with one mock dispatcher that asserts on event names and parameters — no per-provider mocking, no coupling to SDK internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patterns rejected on purpose
&lt;/h2&gt;

&lt;p&gt;The Adapter + Mediator combination was not the first option on the whiteboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://refactoring.guru/design-patterns/observer" rel="noopener noreferrer"&gt;Observer pattern.&lt;/a&gt;&lt;/strong&gt; The textbook choice for "one event, many listeners." But Observer assumes listeners subscribe and unsubscribe dynamically at runtime. Analytics providers don't do that — they're registered at app startup and stay fixed. The subscription mechanism would be dead weight, and the untyped event payloads would lose the structured contract the adapter interface provides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://refactoring.guru/design-patterns/strategy" rel="noopener noreferrer"&gt;Strategy pattern.&lt;/a&gt;&lt;/strong&gt; Strategy selects one behavior from many at runtime. The analytics layer needs the opposite: every registered provider receives every event. Strategy's one-of-N semantics are wrong for a fan-out-to-all scenario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain callback list.&lt;/strong&gt; A &lt;code&gt;List&amp;lt;Function&amp;gt;&lt;/code&gt; that the dispatcher iterates over. Lightweight, but it can't carry consent state, initialization logic, or typed methods like &lt;code&gt;setUserProperty&lt;/code&gt; and &lt;code&gt;trackScreenView&lt;/code&gt;. The moment you need more than fire-and-forget event dispatch, callbacks force you to rebuild the interface anyway.&lt;/p&gt;

&lt;p&gt;The Adapter + Mediator design costs one class per provider instead of a closure. In exchange: typed method contracts, per-provider consent and lifecycle management, and the ability to add provider-specific methods (like Klaviyo's &lt;code&gt;setProfile&lt;/code&gt;) without leaking them into the dispatcher interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  GA4 names as the canonical event language
&lt;/h2&gt;

&lt;p&gt;The dispatcher needs a naming convention for events. Every provider speaks a different dialect — Firebase expects &lt;code&gt;view_item&lt;/code&gt;, Klaviyo expects &lt;code&gt;Viewed Product&lt;/code&gt;, Google Ads maps to conversion IDs. Someone has to pick the canonical format.&lt;/p&gt;

&lt;p&gt;GA4 event names were the right choice for us. They are the de-facto industry standard for e-commerce events, Firebase consumes them natively without any mapping, and every other provider can map from them internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  How events map across providers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Event&lt;/th&gt;
&lt;th&gt;Firebase&lt;/th&gt;
&lt;th&gt;Klaviyo Adapter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_open&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app_open&lt;/code&gt; (custom event)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EventMetric.openedApp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;view_item&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;view_item&lt;/code&gt; (GA4 native)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EventMetric.viewedProduct&lt;/code&gt; (with &lt;code&gt;value&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_to_cart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;add_to_cart&lt;/code&gt; (GA4 native)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EventMetric.addedToCart&lt;/code&gt; (with &lt;code&gt;value&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;search&lt;/code&gt; (GA4 native)&lt;/td&gt;
&lt;td&gt;custom metric (name pass-through)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;screen_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;screen_view&lt;/code&gt; (GA4 native)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app_viewed_page&lt;/code&gt; (custom metric)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Firebase adapter is a thin wrapper — it passes GA4 names straight through because that's what Firebase expects. The Klaviyo adapter does the real mapping work: translating GA4 names into Klaviyo's &lt;code&gt;EventMetric&lt;/code&gt; enum values and restructuring parameters. Google Ads maps events to conversion tracking IDs configured in the adapter.&lt;/p&gt;

&lt;p&gt;The key constraint: the canonical event — including its parameter map — carries enough information for every provider to derive what it needs. If a provider requires a parameter that no other provider uses, the adapter adds it internally rather than polluting the canonical format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consent: the part everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;Consent in a multi-provider analytics setup requires two distinct mechanisms. Most implementations get one right and miss the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mechanism 1: Per-call gating
&lt;/h3&gt;

&lt;p&gt;The dispatcher checks consent before forwarding each event. If the user hasn't consented to Klaviyo, the Klaviyo adapter never fires. This is the mechanism shown in the &lt;code&gt;_checkConsentAndDispatch&lt;/code&gt; method above.&lt;/p&gt;

&lt;p&gt;This works for providers that are passive — they only fire when you call them. Klaviyo is one: the SDK has no runtime collection toggle. There's no &lt;code&gt;Klaviyo.setEnabled(false)&lt;/code&gt;. Once initialized, the SDK is active. The consent gate lives entirely in the dispatch layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mechanism 2: SDK-level consent for auto-collecting providers
&lt;/h3&gt;

&lt;p&gt;Firebase is different. Even if the dispatch layer blocks all &lt;code&gt;trackEvent&lt;/code&gt; calls, Firebase auto-collects screen views, session starts, and first opens in the background. Those bypass your code entirely.&lt;/p&gt;

&lt;p&gt;To stop that collection, you need &lt;code&gt;FirebaseAnalytics.setConsent()&lt;/code&gt; with Google Consent Mode v2 parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;analyticsStorage&lt;/code&gt; — controls measurement data storage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adStorage&lt;/code&gt; — controls ad-related storage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adUserData&lt;/code&gt; — controls sending user data for ads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adPersonalization&lt;/code&gt; — controls ad personalization signals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Firebase adapter overrides &lt;code&gt;applyConsent&lt;/code&gt; to call &lt;code&gt;setConsent()&lt;/code&gt; in addition to &lt;code&gt;setEnabled()&lt;/code&gt;. Without this, Firebase collects data even when your dispatch layer thinks it's blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  The consent lookup
&lt;/h3&gt;

&lt;p&gt;The consent resolver matches each provider's &lt;code&gt;templateId&lt;/code&gt; against the entries in the consent management platform. In this project, Usercentrics stores each data processor (Firebase, Klaviyo, Google Ads) under a unique template ID. The resolver performs an exact-match lookup — the same ID that appears in the Usercentrics dashboard must match the &lt;code&gt;templateId&lt;/code&gt; on the &lt;code&gt;AnalyticsProviderId&lt;/code&gt; enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof it works: the next provider was cheap
&lt;/h2&gt;

&lt;p&gt;The clearest validation of an abstraction is what happens when you extend it.&lt;/p&gt;

&lt;p&gt;Google Ads was added after Klaviyo. The scope: one new adapter class implementing &lt;code&gt;IAnalyticsAdapter&lt;/code&gt;, mapping GA4 events to Google Ads conversion tracking calls, and registering the adapter in the provider list. Zero changes to any call site. Zero changes to the dispatcher. The Google Ads PR touched exactly the files it should have touched — the adapter implementation and the provider registration — and nothing else.&lt;/p&gt;

&lt;p&gt;Meta was estimated but not built during this project phase. The estimate for the Meta adapter was ~4.25 PT — but a large portion of that budget was SDK setup, platform configuration (Facebook App ID, iOS privacy manifest, Android manifest entries), and test infrastructure. The analytics wiring itself — the adapter class and the event mapping — was estimated at a fraction of the total. That's the point: the layer already exists. The expensive part of adding a new provider is the provider's own setup, not plugging it into your analytics architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the dispatch layer
&lt;/h2&gt;

&lt;p&gt;The single-interface design makes the analytics layer straightforward to test. Call sites test against a mock dispatcher — no per-provider SDK mocking, no coupling to Firebase or Klaviyo internals. The dispatcher itself is tested with stub adapters that record calls, verifying consent gating (provider skipped when consent is missing), error isolation (one adapter throwing does not prevent others from receiving the event), and fan-out (all consented adapters receive every event). The adapter tests are integration-level: each adapter receives a canonical GA4 event and asserts that the correct SDK method was called with the expected parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for your analytics layer
&lt;/h2&gt;

&lt;p&gt;The investment — about 2–3 person-days for the core abstraction — amortizes the moment you add a second provider.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the layer before the provider.&lt;/strong&gt; The analytics dispatcher was the largest work package in the Klaviyo project, but it's the one that keeps paying returns. Every subsequent provider integration is scoped to an adapter class and a registration line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GA4 as canonical is a no-brainer.&lt;/strong&gt; Any other naming scheme forces you to invent a translation table that Firebase has already standardized. Start with GA4 names; let adapters translate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent needs two mechanisms, not one — and audit your bypasses before release.&lt;/strong&gt; The dispatch-layer gate handles providers without runtime toggles. It does not stop auto-collecting SDKs. If your only consent mechanism is "don't call the provider," Firebase is still collecting behind your back. And a hardcoded testing shortcut in the consent resolver is a GDPR incident waiting to happen — treat it like a credential that should never exist on the main branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this project, the second provider was Klaviyo, and the third was Google Ads. Neither required touching a single call site. The &lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;Klaviyo Flutter integration overview&lt;/a&gt; covers the full integration — push notifications, identity, and consent — that this layer made possible.&lt;/p&gt;

&lt;p&gt;Wrangling multiple analytics SDKs in your Flutter app? I've built this exact dispatcher pattern in production — &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;let's grab a coffee&lt;/a&gt; and I'll sketch how the adapter layer maps to your provider mix.&lt;br&gt;
See how I approach &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;Flutter app development&lt;/a&gt; projects end-to-end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-shopify-integration-flutter-app" rel="noopener noreferrer"&gt;Klaviyo Shopify Integration in Flutter: What to Know First&lt;/a&gt; — the full project scope: four workstreams, five weeks, and the purchase tracking gap.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;Klaviyo Push Notifications in Flutter: 3 Silent Pitfalls&lt;/a&gt; — the push layer the analytics dispatcher feeds into.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;Klaviyo Profiles &amp;amp; Newsletter: Where the Docs Won't Save You&lt;/a&gt; — identity and newsletter traps that surfaced after the analytics layer was stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not just call each analytics SDK directly in Flutter?
&lt;/h3&gt;

&lt;p&gt;Direct calls couple every screen and controller to every SDK. With ~10 call sites and three providers, that is 30 integration points, each needing its own consent check. A dispatcher reduces this to one call-site contract, with consent and error handling implemented once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which design pattern fits a multi-provider analytics layer?
&lt;/h3&gt;

&lt;p&gt;A combination of the Adapter pattern (one adapter per SDK, mapping canonical events to the SDK-specific format) and a Mediator/Facade (the dispatcher that fans events out to all consented adapters in parallel). Observer is over-engineered since providers are fixed at startup. Strategy is wrong because you fan out to all providers, not select one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What event naming should a Flutter analytics layer use?
&lt;/h3&gt;

&lt;p&gt;GA4 event names (&lt;code&gt;view_item&lt;/code&gt;, &lt;code&gt;add_to_cart&lt;/code&gt;, &lt;code&gt;begin_checkout&lt;/code&gt;, &lt;code&gt;purchase&lt;/code&gt;) as the canonical format. They are the industry baseline, Firebase consumes them natively, and every other provider can translate from them internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should consent be handled with multiple analytics providers?
&lt;/h3&gt;

&lt;p&gt;Two mechanisms are needed. Per-call gating in the dispatcher covers providers whose SDK has no runtime toggle (like Klaviyo). Providers that auto-collect in the background (like Firebase with Google Consent Mode v2) additionally need the consent status pushed into the SDK via its native consent API. The dispatcher should support both.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Klaviyo Shopify Integration in Flutter: What to Know First</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Thu, 16 Jul 2026 18:25:07 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/klaviyo-shopify-integration-in-flutter-what-to-know-first-139d</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/klaviyo-shopify-integration-in-flutter-what-to-know-first-139d</guid>
      <description>&lt;p&gt;&lt;em&gt;For CTOs, tech leads, and senior developers evaluating a Klaviyo integration for a mobile e-commerce app — or about to scope one. If you're looking for a step-by-step SDK installation guide, the &lt;a href="https://developers.klaviyo.com/" rel="noopener noreferrer"&gt;Klaviyo docs&lt;/a&gt; cover that well. This post is about what happens after installation — the architecture, integration, and scoping decisions that determine whether the project takes two weeks or two months.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Klaviyo × Flutter series&lt;/strong&gt; (part 1 of 4): planning &amp;amp; scope · &lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;the analytics layer&lt;/a&gt; · &lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;push notification pitfalls&lt;/a&gt; · &lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;profiles &amp;amp; newsletter&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; installs in minutes. The production integration for a Shopify-based DTC brand took five weeks and six PRs. The single largest investment was a unified analytics abstraction layer that made Klaviyo a clean adapter plug-in — and the one gap no app-side code can close is purchase tracking, because checkout opens in the browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "install the SDK" is the wrong mental model
&lt;/h2&gt;

&lt;p&gt;When a client asks about a Klaviyo Shopify integration for their Flutter app, the conversation usually starts with the SDK. "How long to install it?" The answer — an hour, maybe two — is technically correct and completely misleading.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; wraps the native Klaviyo iOS and Android SDKs. It covers profile identification, event tracking, and push token registration. You add the dependency, call &lt;code&gt;Klaviyo.initialize(apiKey)&lt;/code&gt;, and the SDK is running. But the SDK is a wire — it doesn't decide what flows through it.&lt;/p&gt;

&lt;p&gt;A production integration means answering: How do analytics events reach Klaviyo without duplicating the dispatch logic you already have for Firebase? How do push notifications coexist with your existing &lt;code&gt;firebase_messaging&lt;/code&gt; setup? How does the app identify a profile without creating duplicates against Klaviyo's own Shopify sync? And how does any of this respect the user's consent choices?&lt;/p&gt;

&lt;p&gt;Those four questions are four workstreams. Each one has at least one non-obvious problem. Here's what they looked like in practice, working on a Shopify-based DTC jewelry brand's Flutter app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start: what the client must provide
&lt;/h2&gt;

&lt;p&gt;Before a Klaviyo integration can begin, three things need to come from outside the development team:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Klaviyo public API key (site ID)&lt;/strong&gt; — the identifier the SDK uses to associate events and profiles with your Klaviyo account. Found in Klaviyo account settings under API keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A Usercentrics data-processor entry for Klaviyo&lt;/strong&gt; — if your app uses a CMP (and in the EU, it does), Klaviyo needs its own entry. This is a legal/compliance configuration, not an SDK task. Without it, the consent gate has nothing to check against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The newsletter list ID&lt;/strong&gt; — Klaviyo organizes subscribers into lists. The app needs the ID of the specific list to subscribe profiles to. Your marketing team creates the list; the developer gets the ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workstream 1: Building a Flutter analytics layer for Klaviyo
&lt;/h2&gt;

&lt;p&gt;Before touching Klaviyo, the app's analytics needed an abstraction layer. The existing setup had ~10 call sites scattered across the codebase, each calling Firebase Analytics and an attribution service directly. No interface, no shared dispatch — every screen that tracked an event imported the Firebase service, constructed the event payload, and fired it.&lt;/p&gt;

&lt;p&gt;Adding Klaviyo into that model would mean visiting every call site, adding a third direct call, and tripling the maintenance surface. The next provider after that — Meta, for example — would do it again.&lt;/p&gt;

&lt;p&gt;The solution was an Adapter + Mediator pattern: a single &lt;code&gt;AnalyticsDispatcher&lt;/code&gt; that receives events through one API and fans them out to per-provider adapters. Each adapter (Firebase, attribution, Klaviyo, and a Meta stub) implements the same interface. The dispatcher iterates over active adapters, checks consent for each, and forwards the event.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;Deep dive: One trackEvent(), Four SDKs — the full architecture →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Workstream 2: Klaviyo push notifications in Flutter
&lt;/h2&gt;

&lt;p&gt;Klaviyo sends push notifications through FCM on Android and APNs on iOS. The &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; handles push token registration — you pass the token to Klaviyo, and Klaviyo associates it with the identified profile. Straightforward in isolation.&lt;/p&gt;

&lt;p&gt;The problem: the app already had a working push setup with &lt;code&gt;firebase_messaging&lt;/code&gt;. On Android, three services competed for the &lt;code&gt;com.google.firebase.MESSAGING_EVENT&lt;/code&gt; intent filter — the app's own &lt;code&gt;FirebaseMessagingService&lt;/code&gt;, Klaviyo's service, and the default FCM service. Android's service resolution dispatches to exactly one. In this case, Klaviyo's service was winning, which meant non-Klaviyo pushes were silently dropped. The fix was routing all incoming messages through a single service and dispatching based on payload markers.&lt;/p&gt;

&lt;p&gt;On iOS, the Notification Service Extension was required for Klaviyo's rich push (images, action buttons). The extension intercepts the notification before display, downloads the media, and attaches it. Setting this up means a separate Xcode target, a shared app group, and careful configuration of the &lt;code&gt;mutable-content&lt;/code&gt; flag. The tap handler also needed gating: Klaviyo pushes carry a &lt;code&gt;_k&lt;/code&gt; marker in the payload, and the app has to distinguish those from its own deep-link pushes to route correctly.&lt;/p&gt;

&lt;p&gt;The collision between existing &lt;code&gt;firebase_messaging&lt;/code&gt; and the Klaviyo SDK was the most time-consuming debugging session of the project. Push notifications that silently disappear produce no error logs — the message arrives, gets dispatched to the wrong service, and vanishes. If you're dealing with push notification complexity beyond Klaviyo, I've also written about &lt;a href="https://khal.it/blog/smart-push-notifications-jitai-framework" rel="noopener noreferrer"&gt;designing context-aware push notification systems&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;Deep dive: Klaviyo Push Notifications in Flutter — three pitfalls →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Workstream 3: Klaviyo profile identity &amp;amp; newsletter in Flutter
&lt;/h2&gt;

&lt;p&gt;Klaviyo identifies profiles by email. The Shopify backend also syncs customer data to Klaviyo via the native Shopify-Klaviyo integration. This creates a coordination problem: the app and the backend both write to the same Klaviyo account, and they need to agree on identity.&lt;/p&gt;

&lt;p&gt;The initial approach — passing &lt;code&gt;external_id&lt;/code&gt; alongside email to link app-created profiles with Shopify-synced ones — backfired. Klaviyo's profile merge logic treated &lt;code&gt;external_id&lt;/code&gt; as a strong identifier. Instead of merging profiles, it suppressed auto-merge and created duplicates. The fix was using email-only identification from the app side and letting Klaviyo's own merge logic handle deduplication against the Shopify sync.&lt;/p&gt;

&lt;p&gt;Newsletter subscription added another asymmetry. Subscribing a profile to a list works through Klaviyo's client API — the app calls &lt;code&gt;Klaviyo.subscribeToList(listId)&lt;/code&gt; and the profile gets added. Unsubscribing, however, is not available through the client SDK. The solution was an asymmetric flow: unsubscribe triggers a custom event from the app, which fires a Klaviyo flow, which calls a webhook to remove the profile from the list. Subscribe is synchronous and direct; unsubscribe is event-driven and indirect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;Deep dive: Klaviyo Profiles &amp;amp; Newsletter — where the docs won't save you →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Workstream 4: Consent management for Klaviyo with Usercentrics
&lt;/h2&gt;

&lt;p&gt;Every analytics provider in the app is gated on user consent, managed through a Usercentrics CMP (Consent Management Platform). No consent, no tracking — GDPR requires it, and in practice it means the consent config blocks or unblocks each provider independently.&lt;/p&gt;

&lt;p&gt;Klaviyo doesn't have a runtime collection toggle. There's no &lt;code&gt;Klaviyo.setEnabled(false)&lt;/code&gt; — once initialized, the SDK is active. The consent gate lives in the dispatch layer: the &lt;code&gt;AnalyticsDispatcher&lt;/code&gt; checks the user's consent state for Klaviyo before forwarding any event to the Klaviyo adapter. If consent isn't granted, the adapter never fires.&lt;/p&gt;

&lt;p&gt;Firebase requires a different mechanism. Beyond the dispatch-layer gate, Firebase has auto-collection behaviors (screen views, session starts) that ignore your code-level event calls. Those need &lt;code&gt;FirebaseAnalytics.setConsent()&lt;/code&gt; with Google Consent Mode v2 parameters — &lt;code&gt;analyticsStorage&lt;/code&gt;, &lt;code&gt;adStorage&lt;/code&gt;, &lt;code&gt;adUserData&lt;/code&gt;, &lt;code&gt;adPersonalization&lt;/code&gt;. Without this, Firebase collects data even when your dispatch layer blocks it.&lt;/p&gt;

&lt;p&gt;The dispatcher supports both mechanisms: per-call gating for providers like Klaviyo that lack a native toggle, and SDK-level consent APIs for providers like Firebase that have one. Both check the same consent state from Usercentrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shopify checkout problem: why Flutter can't track purchases
&lt;/h2&gt;

&lt;p&gt;The app tracks three native Klaviyo metrics: &lt;code&gt;openedApp&lt;/code&gt;, &lt;code&gt;viewedProduct&lt;/code&gt;, and &lt;code&gt;addedToCart&lt;/code&gt;. These fire from app code, flow through the dispatch layer, and land in Klaviyo for segmentation and flow triggers.&lt;/p&gt;

&lt;p&gt;There's a fourth metric you'd expect: &lt;code&gt;purchase&lt;/code&gt;. It's absent — and it can't be added from the app.&lt;/p&gt;

&lt;p&gt;The reason: checkout &lt;a href="https://khal.it/blog/webview-app-payment-flows-state-sync-teil-2" rel="noopener noreferrer"&gt;opens in the system browser&lt;/a&gt;. The user taps "Buy now," the app launches a Shopify checkout URL in Safari or Chrome, and the purchase completes there. The app has no callback, no webhook, no way to know that the purchase happened. The browser session is outside the app's process, and Shopify's checkout doesn't call back into the native app.&lt;/p&gt;

&lt;p&gt;This means purchase-based flows in Klaviyo — post-purchase emails, win-back sequences based on order history, revenue attribution — can't be triggered from app-side events. The data has to come from somewhere else.&lt;/p&gt;

&lt;p&gt;Three options exist, all outside the app's scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shopify web pixels:&lt;/strong&gt; Shopify's native analytics integration can forward checkout events to Klaviyo. This runs entirely in the Shopify checkout, no app involvement needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order webhooks to a backend:&lt;/strong&gt; Shopify fires order webhooks (&lt;code&gt;orders/create&lt;/code&gt;) to a backend service, which calls Klaviyo's server API to record the purchase event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid:&lt;/strong&gt; Combine web pixels for real-time tracking with webhooks as a fallback for reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The choice depends on the client's infrastructure. If they have a backend that already processes Shopify webhooks, the webhook path is straightforward. If they don't, Shopify web pixels are the lower-maintenance option.&lt;/p&gt;

&lt;p&gt;The key point: this decision needs to happen before or during the integration, not after. If the marketing team expects purchase-based flows in Klaviyo and the integration plan only covers app-side code, there's a gap that no amount of Flutter development will close.&lt;/p&gt;

&lt;p&gt;Hitting the same Shopify checkout gap? I've navigated this with Shopify-backed Flutter apps — &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;let's map your options&lt;/a&gt; in 15 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real timeline
&lt;/h2&gt;

&lt;p&gt;The project ran for about five calendar weeks, delivered across six pull requests. The phasing was deliberate: foundation first, then call-site migration, then provider integration, then QA.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work Package&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core abstraction&lt;/td&gt;
&lt;td&gt;AnalyticsProvider interface, AnalyticsDispatcher&lt;/td&gt;
&lt;td&gt;2 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firebase adapter&lt;/td&gt;
&lt;td&gt;Wrap existing service&lt;/td&gt;
&lt;td&gt;2.5 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attribution adapter&lt;/td&gt;
&lt;td&gt;Wrap existing service + provider&lt;/td&gt;
&lt;td&gt;1 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Riverpod wiring + consent&lt;/td&gt;
&lt;td&gt;Provider setup, ConsentController&lt;/td&gt;
&lt;td&gt;0.25 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call-site migration&lt;/td&gt;
&lt;td&gt;Replace in ~10 files (~15 call points)&lt;/td&gt;
&lt;td&gt;1 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Klaviyo SDK integration&lt;/td&gt;
&lt;td&gt;Dependency, adapter, native setup&lt;/td&gt;
&lt;td&gt;2.5 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta stub&lt;/td&gt;
&lt;td&gt;Placeholder ready for activation&lt;/td&gt;
&lt;td&gt;0.25 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA &amp;amp; regression&lt;/td&gt;
&lt;td&gt;All events, consent toggling, Klaviyo&lt;/td&gt;
&lt;td&gt;1 PT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~10 PT&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Five scoping mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the abstraction layer.&lt;/strong&gt; Adding Klaviyo to scattered direct calls creates a migration inside a migration. The dispatch layer was about 4.5 PT upfront, but it made every subsequent provider integration a clean adapter plug-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Underestimating push notification conflicts.&lt;/strong&gt; When two services compete for the same Android intent filter, the loser doesn't crash — it never gets called. No error logs, no exceptions. Budget time for debugging silent push failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;external_id&lt;/code&gt; for identity against a Shopify-synced account.&lt;/strong&gt; It seems right — link app profiles to Shopify profiles by ID. In practice, it suppresses Klaviyo's auto-merge and creates duplicates. Email-only identification is the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating consent as a global toggle.&lt;/strong&gt; Different providers need different consent mechanisms. A dispatch-layer gate works for Klaviyo. Firebase needs its own &lt;code&gt;setConsent()&lt;/code&gt; API call on top. Build for per-provider consent from the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovering the purchase tracking gap after the SDK is integrated.&lt;/strong&gt; This is the one thing that determines whether the integration meets marketing's expectations. Raise it in the first scoping conversation.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A Klaviyo integration into a production Flutter e-commerce app is four workstreams: analytics abstraction, push notifications, identity and newsletter, and consent. The SDK itself is a small part of the total effort — the architecture around it is where the weeks go. The integration shipped to production and has been running since.&lt;/p&gt;

&lt;p&gt;The most important thing to get right isn't code. It's scoping the purchase tracking gap before development starts, so the marketing team's expectations match what the app can deliver. Everything the app can track — opens, product views, cart additions — flows through cleanly. Purchases don't, and that's a Shopify/backend decision, not a Flutter one.&lt;/p&gt;

&lt;p&gt;Planning a Klaviyo integration for your Flutter or mobile app? I've done this in production — &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;book a casual coffee chat&lt;/a&gt; and I'll walk you through what it takes for your setup. Or continue reading the series: &lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;the analytics layer&lt;/a&gt; · &lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;push notification pitfalls&lt;/a&gt; · &lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;profiles &amp;amp; newsletter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;See how I approach &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;Flutter app development&lt;/a&gt; projects end-to-end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/flutter-unified-analytics-layer" rel="noopener noreferrer"&gt;One trackEvent(), Four SDKs: Analytics Layer for Flutter&lt;/a&gt; — the dispatcher architecture that made Klaviyo a clean adapter plug-in.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;Klaviyo Push Notifications in Flutter: 3 Silent Pitfalls&lt;/a&gt; — competing Android services, broken tap handlers, and write-once channel importance.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-profiles-newsletter-flutter" rel="noopener noreferrer"&gt;Klaviyo Profiles &amp;amp; Newsletter: Where the Docs Won't Save You&lt;/a&gt; — duplicate profiles from &lt;code&gt;external_id&lt;/code&gt; and the asymmetric unsubscribe flow.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/smart-push-notifications-jitai-framework" rel="noopener noreferrer"&gt;Smart Push Notifications: The JITAI Framework&lt;/a&gt; — designing context-aware push strategies beyond basic delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Klaviyo have a Flutter SDK?
&lt;/h3&gt;

&lt;p&gt;Yes. The &lt;code&gt;klaviyo_flutter_sdk&lt;/code&gt; package wraps the native Klaviyo iOS and Android SDKs and covers profile management, event tracking, and push notifications. It handles push token registration via FCM on Android and APNs on iOS.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does a Klaviyo integration into a Flutter app take?
&lt;/h3&gt;

&lt;p&gt;For a production e-commerce app, the realistic scope is several weeks, not days. In the project this article is based on, the work spanned about five weeks and six pull requests across a unified analytics layer, the Klaviyo SDK integration, push notifications, newsletter and identity handling, and consent management. The analytics abstraction alone — building the dispatch layer and migrating existing call sites — accounted for roughly half the total effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the client need to provide before a Klaviyo integration starts?
&lt;/h3&gt;

&lt;p&gt;Three things: the Klaviyo public API key (site ID), a Klaviyo entry in the consent management platform (e.g. Usercentrics), and the ID of the newsletter list the app should subscribe profiles to. All three require action from someone outside the development team — account settings, legal/compliance configuration, and marketing setup respectively.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the app track purchases in Klaviyo if checkout runs in the browser?
&lt;/h3&gt;

&lt;p&gt;Not from the app alone. If checkout opens in an external browser, the app loses context and cannot emit purchase events. The app can track &lt;code&gt;openedApp&lt;/code&gt;, &lt;code&gt;viewedProduct&lt;/code&gt;, and &lt;code&gt;addedToCart&lt;/code&gt;, but purchase tracking requires a Shopify-side or server-side solution. Options include Shopify web pixels, order webhooks to a backend, or a hybrid of both.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Akamai Bot Protection in Flutter: BMP SDK Integration</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Wed, 08 Jul 2026 13:16:24 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/akamai-bot-protection-in-flutter-bmp-sdk-integration-10j8</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/akamai-bot-protection-in-flutter-bmp-sdk-integration-10j8</guid>
      <description>&lt;p&gt;&lt;em&gt;For CTOs, tech leads, and senior developers building enterprise mobile apps behind Akamai infrastructure — or planning to.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Akamai sits between your mobile app and your backend - providing CDN, image optimization, and bot protection. The problem: bot detection was designed for browsers, and your native app looks like a bot. Akamai provides a BMP (Bot Manager Premier) SDK for Flutter that collects behavioral sensor data and sends it via HTTP headers. But the SDK gives you the primitives - the orchestration is on you. I spent months integrating the BMP SDK into a Flutter e-commerce app for a Swiss electronics retailer: building a Dio interceptor chain for sensor data injection, handling two tiers of bot responses (428 challenges and 403 blocks), and configuring path-based URL filtering with remote config.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: Akamai Everywhere
&lt;/h2&gt;

&lt;p&gt;The client is a Swiss electronics retailer. Their infrastructure runs through Akamai - CDN for static assets, Image Manager for responsive image delivery, and Akamai Bot Manager for bot detection and mitigation. Every request from every client passes through Akamai before it reaches the backend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fakamai-request-flow-en.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fakamai-request-flow-en.svg" alt="Request Flow: Flutter App → Akamai Edge Network → Backend" width="900" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For their website, this is invisible. The browser handles cookies, executes JavaScript challenges, and maintains session state. Akamai was built for this exact scenario.&lt;/p&gt;

&lt;p&gt;For a native Flutter app making HTTP requests with Dio? Different story.&lt;/p&gt;

&lt;p&gt;I worked on this project for about ten months, building the &lt;a href="https://khal.it/blog/two-ecommerce-apps-one-architecture" rel="noopener noreferrer"&gt;app from the ground up&lt;/a&gt;. The Akamai integration touched the networking layer, image loading, authentication flow, and checkout. This post covers the four main integration surfaces: Image Manager, the BMP SDK, the interceptor chain, and challenge handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image Manager: Optimized Images from the CDN Edge
&lt;/h2&gt;

&lt;p&gt;Akamai Image Manager transforms images on the CDN edge. You pass parameters as URL query strings, and Akamai returns an optimized image - resized, compressed, format-converted. No image processing server needed.&lt;/p&gt;

&lt;p&gt;For CMS content images served through a dedicated asset host, the integration is a string extension that builds the optimized URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;toOptimizedContentUrl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;https&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'assets.example.ch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;'imdensity'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'impolicy'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'contentstack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'imwidth'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'480'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;impolicy&lt;/code&gt; parameter selects a transformation preset configured in Akamai's control panel. &lt;code&gt;imwidth&lt;/code&gt; sets the target width, and &lt;code&gt;imdensity&lt;/code&gt; accounts for high-DPI screens. The CDN caches each variant at the edge, so the second request for the same parameters is instant.&lt;/p&gt;

&lt;p&gt;This extension gets applied wherever the app renders CMS-managed content - home feed items, banners, product badges, search suggestions. One centralized transformation, used in over a dozen places across the app.&lt;/p&gt;

&lt;p&gt;This was the straightforward part of the Akamai integration. Everything after this required more architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the App Got Blocked
&lt;/h2&gt;

&lt;p&gt;Launch was approaching, and with it a deliberate decision: the Akamai rules were tightened. The app needed protection against DDoS attacks and automated abuse. Clients now had to actively prove they weren't bots.&lt;/p&gt;

&lt;p&gt;For the web shop, this was a non-issue. Browser traffic brings everything &lt;a href="https://www.akamai.com/products/bot-manager" rel="noopener noreferrer"&gt;Akamai Bot Manager&lt;/a&gt; needs: the &lt;code&gt;_abck&lt;/code&gt; session cookie from previous interactions, a recognizable User-Agent like &lt;code&gt;Mozilla/5.0&lt;/code&gt;, and the ability to execute Akamai's &lt;a href="https://techdocs.akamai.com/cloud-security/docs/detection-methods" rel="noopener noreferrer"&gt;sensor script&lt;/a&gt; — a JavaScript that inventories canvas rendering, WebGL hashes, installed fonts, and dozens of other signals to distinguish real browsers from automation.&lt;/p&gt;

&lt;p&gt;A native app on its first API request has none of those signals. No &lt;code&gt;_abck&lt;/code&gt; cookie. No behavioral fingerprint. No JavaScript context. And a User-Agent that says something like &lt;code&gt;Dio/5.0&lt;/code&gt; — Dart's HTTP client — instead of &lt;code&gt;Mozilla/5.0&lt;/code&gt;. Akamai scores every request with a &lt;a href="https://techdocs.akamai.com/edgeworkers/docs/botscore-object" rel="noopener noreferrer"&gt;Bot Score&lt;/a&gt; from 0 (human) to 100 (bot). A standard HTTP client without sensor data scores &lt;a href="https://brightdata.com/blog/web-data/bypass-akamai-bot-detection" rel="noopener noreferrer"&gt;near 100&lt;/a&gt; — maximum bot confidence.&lt;/p&gt;

&lt;p&gt;The result: 403 Forbidden. But not everywhere at once. Bot Manager allows &lt;a href="https://techdocs.akamai.com/terraform/docs/bmgr-rc-transactional-endpoint-protection" rel="noopener noreferrer"&gt;transactional endpoints&lt;/a&gt; to be configured individually — login, checkout, and account pages can be protected with stricter thresholds than product listings or search endpoints. In practice: product images load, but login fails. Or the cart works, but checkout doesn't. Not consistently reproducible, because the Bot Score is dynamic — the same request can be evaluated differently depending on IP reputation, &lt;a href="https://www.akamai.com/blog/security/bots-tampering-with-tls-to-avoid-detection" rel="noopener noreferrer"&gt;TLS fingerprint&lt;/a&gt;, and time of day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The BMP SDK: Proving You're Not a Bot
&lt;/h2&gt;

&lt;p&gt;Akamai provides a Bot Manager Premier (BMP) SDK for Flutter - a native plugin that collects behavioral sensor data on the device. Touch events, device motion, screen interactions - the SDK builds a fingerprint that Akamai's edge servers can evaluate to distinguish human users from bots.&lt;/p&gt;

&lt;p&gt;The SDK ships as a platform plugin wrapping native libraries (an AAR for Android, an xcframework for iOS). Integration starts in &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bmp_flutter_sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;packages/bmp_flutter_sdk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initialization happens at app startup. You pass the base URL of the protected resource, and optionally enable the challenge action feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_initializeAkamaiSdk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShopConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;configureSDK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;baseUrl:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;configureChallengeAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;baseUrl:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once initialized, the SDK silently collects behavioral data in the background. When you need to make a protected API call, you retrieve the sensor data and send it as an HTTP header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSensorData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'X-acf-sensor-data'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Akamai's edge server inspects this header, evaluates the sensor data, and decides whether the request looks human. If it does, the request passes through to the origin. If it doesn't, the server responds with either a 428 (challenge) or a 403 (block).&lt;/p&gt;

&lt;p&gt;The SDK handles the hard part - native sensor data collection across platforms. But the orchestration - deciding which requests need sensor data, handling challenge responses, retrying failed requests - that's your job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Interceptor Chain
&lt;/h2&gt;

&lt;p&gt;The real architecture work is the Dio interceptor chain — the same pattern I use for &lt;a href="https://khal.it/blog/flutter-token-refresh-silent-logout" rel="noopener noreferrer"&gt;token refresh and silent logout&lt;/a&gt;. Four interceptors, each with a single responsibility, executed in order on every request:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Sensor Data Enrichment
&lt;/h3&gt;

&lt;p&gt;The first interceptor checks if the outgoing request targets an Akamai-protected URL. If it does, it retrieves sensor data from the BMP SDK and injects it as the &lt;code&gt;X-acf-sensor-data&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AkamaiSensorDataEnrichmentInterceptor&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Interceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AkamaiProtectedUrlRule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;RequestOptions&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RequestInterceptorHandler&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matchUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSensorData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sensorData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'X-acf-sensor-data'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Challenge Action (HTTP 428)
&lt;/h3&gt;

&lt;p&gt;If Akamai isn't sure whether the request is from a human, it returns HTTP 428 (Precondition Required) with an &lt;code&gt;Akamai-BM-Challenge-Context&lt;/code&gt; header. The interceptor catches this, presents a native challenge dialog to the user via the SDK, and retries on success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AkamaiChallengeActionInterceptor&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Interceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Dio&lt;/span&gt; &lt;span class="n"&gt;_dio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AkamaiProtectedUrlRule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DioException&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ErrorInterceptorHandler&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;428&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matchUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;challengeContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Akamai-BM-Challenge-Context'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;challengeContext&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ccaResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;showChallengeAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;context:&lt;/span&gt; &lt;span class="n"&gt;challengeContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="s"&gt;'Verification Required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;message:&lt;/span&gt; &lt;span class="s"&gt;'Please wait while we verify your request.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;cancelButtonTitle:&lt;/span&gt; &lt;span class="s"&gt;'Cancel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ccaResponse&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Challenge passed - retry with fresh sensor data&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSensorData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;retryOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;headers:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;'X-acf-sensor-data'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sensorData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;retryResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_dio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;retryOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The challenge dialog is native - the SDK renders it, not a WebView. The user sees a brief verification screen, and on success the request retries transparently.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bot Protection (HTTP 403)
&lt;/h3&gt;

&lt;p&gt;If Akamai is confident the request is from a bot, it returns HTTP 403 with a &lt;code&gt;reference_id&lt;/code&gt; in the response body. Unlike the 428 challenge, this is terminal - there's no way to solve it programmatically. The interceptor shows a support dialog with the reference ID so the user can contact support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BotProtectionInterceptor&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Interceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AkamaiProtectedUrlRule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DioException&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrorInterceptorHandler&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;referenceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
          &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'reference_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'reference_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;referenceId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;akamaiProtectedUrlRules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matchUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Show bot detection dialog with reference ID and support contact&lt;/span&gt;
      &lt;span class="n"&gt;_showBotDetectionDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;referenceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction between 428 and 403 matters. A 428 is a question: "Are you human? Prove it." A 403 with a reference ID is a verdict: "We're blocking you." Different responses, different UX flows.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Session Cookie Monitoring
&lt;/h3&gt;

&lt;p&gt;A fourth interceptor monitors the application's session cookie on authenticated requests and logs to crash reporting when it's missing. This isn't Akamai-specific - it catches cases where the session state got corrupted, regardless of cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL-Based Filtering
&lt;/h2&gt;

&lt;p&gt;Not every request needs Akamai protection. Product listings, image URLs, static content - these don't need sensor data. Only high-value endpoints like authentication and checkout warrant the overhead.&lt;/p&gt;

&lt;p&gt;The filtering uses path-based rules with three match types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AkamaiProtectedUrlRule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;RuleType&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// pathEquals, pathContains, pathRegex&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Uri&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;RuleType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pathEquals&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;RuleType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pathContains&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;RuleType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pathRegex&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protected endpoints in this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/accounts/authenticate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/accounts/registration&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/accounts/changeemail&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/accounts/forgotpassword&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/accounts/resetpassword&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/checkout/init&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/checkout/validate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/checkout/order&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These rules are defined as fallback defaults but can be overridden via Firebase Remote Config. When the security team adjusts which endpoints are protected, the app picks up the change without a release.&lt;/p&gt;

&lt;p&gt;Why does this matter? Two reasons. First, &lt;code&gt;getSensorData()&lt;/code&gt; has a cost - it's an async call that adds latency. Calling it on every request to every endpoint is wasteful. Second, the SDK docs explicitly say: "Don't call the getSensorData method for non-protected URLs." You're collecting behavioral data, and sending it unnecessarily is both a performance and a privacy concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing and Debug Tooling
&lt;/h2&gt;

&lt;p&gt;You can't run Akamai's bot detection locally. The evaluation happens on Akamai's edge servers, not on your machine. But you can test the interceptor logic and verify the SDK integration.&lt;/p&gt;

&lt;p&gt;The BMP SDK includes built-in integration checks. The app exposes these on a debug page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run integration diagnostics&lt;/span&gt;
&lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runIntegrationChecks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;AkamaiBMP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runIntegrationChecksWithCCAEnabled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These verify that the SDK initialized correctly, sensor data collection works, device motion sensors are accessible, and the challenge action feature is configured. The SDK logs results to the console with a clear pass/fail for each check.&lt;/p&gt;

&lt;p&gt;For the interceptor chain, mock interceptors in CI test the retry logic and URL filtering independently of Akamai's edge servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'enriches protected URL with sensor data header'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;interceptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AkamaiSensorDataEnrichmentInterceptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;akamaiProtectedUrlRules:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="n"&gt;AkamaiProtectedUrlRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;RuleType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pathContains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;rule:&lt;/span&gt; &lt;span class="s"&gt;'/api/checkout/order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RequestOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;path:&lt;/span&gt; &lt;span class="s"&gt;'/api/checkout/order'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MockRequestInterceptorHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;interceptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'X-acf-sensor-data'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;isNotNull&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A staging environment with Akamai enabled remains the only way to verify the full flow end-to-end. Local development uses mock interceptors, but every change to the networking layer gets tested against staging before merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;A few things that aren't obvious from the SDK documentation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The challenge action response has three states, not two.&lt;/strong&gt; Status &lt;code&gt;1&lt;/code&gt; means success, &lt;code&gt;0&lt;/code&gt; means the user canceled, and &lt;code&gt;-1&lt;/code&gt; means the challenge failed. Handle all three explicitly - cancellation and failure need different error messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensor data should be fresh for retries.&lt;/strong&gt; After a successful challenge action, don't reuse the sensor data from the failed request. Call &lt;code&gt;getSensorData()&lt;/code&gt; again to get fresh behavioral data. The edge server expects sensor data that reflects the current session state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SDK needs initialization before the first API call.&lt;/strong&gt; Call &lt;code&gt;configureSDK()&lt;/code&gt; early in the app lifecycle - before any protected API request fires. If the SDK hasn't collected enough behavioral data when the first request goes out, the sensor payload will be thin, and Akamai is more likely to challenge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL rule changes require no app release.&lt;/strong&gt; Making the protected URL rules configurable via remote config was one of the better architectural decisions. When the security team added new protected endpoints mid-sprint, we updated the rules without a release cycle.&lt;/p&gt;

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

&lt;p&gt;Akamai's BMP SDK handles the hard part - native behavioral sensor data collection across iOS and Android. The challenge is everything around it: building the interceptor chain, handling the two-tier response system, configuring URL-based filtering, and building the debug tooling to verify it all works.&lt;/p&gt;

&lt;p&gt;The interceptor chain - sensor data enrichment, challenge action handling, bot protection, session monitoring - is compact. Each interceptor has a single responsibility, and the URL rule system ensures they only fire for endpoints that need protection.&lt;/p&gt;

&lt;p&gt;Three takeaways from this project:&lt;/p&gt;

&lt;p&gt;Plan the Akamai integration from the start. If you know your backend sits behind Akamai, begin with the SDK integration and the interceptor chain - not when the 403 errors start arriving.&lt;/p&gt;

&lt;p&gt;Keep the URL rules updatable. Remote config for the protected endpoints isn't an optimization - it's an operational necessity when the security team needs to adjust rules independently of the app release cycle.&lt;/p&gt;

&lt;p&gt;The SDK integration itself takes days. The interceptor architecture, URL filtering, remote config, debug tooling, and testing against staging - that's where the real time goes.&lt;/p&gt;

&lt;p&gt;I've built this integration end-to-end for a production e-commerce app. If your team is planning a similar integration, &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Akamai Bot Manager and how does it affect mobile apps?
&lt;/h3&gt;

&lt;p&gt;Akamai Bot Manager is a bot detection and mitigation service that sits between clients and origin servers. It evaluates incoming requests using behavioral analysis and device fingerprinting. Mobile apps need the BMP SDK to provide sensor data via the &lt;code&gt;X-acf-sensor-data&lt;/code&gt; HTTP header, which Akamai's edge servers evaluate to distinguish legitimate app traffic from bots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Akamai provide a mobile SDK for bot protection?
&lt;/h3&gt;

&lt;p&gt;Yes. Akamai provides a Bot Manager Premier (BMP) SDK for Flutter, iOS, and Android. The Flutter SDK (&lt;code&gt;bmp_flutter_sdk&lt;/code&gt;) wraps native platform libraries and provides methods for initialization (&lt;code&gt;configureSDK&lt;/code&gt;), sensor data collection (&lt;code&gt;getSensorData&lt;/code&gt;), and challenge handling (&lt;code&gt;showChallengeAction&lt;/code&gt;). You integrate it as a local Flutter plugin package.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between a 428 and a 403 from Akamai?
&lt;/h3&gt;

&lt;p&gt;A 428 (Precondition Required) means Akamai wants additional verification. The response includes an &lt;code&gt;Akamai-BM-Challenge-Context&lt;/code&gt; header, and the app can present a challenge dialog via the SDK. If the user passes, the request can be retried with fresh sensor data. A 403 (Forbidden) with a &lt;code&gt;reference_id&lt;/code&gt; means Akamai has classified the request as a bot - this is terminal and can't be solved programmatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Akamai Image Manager work with Flutter's image caching?
&lt;/h3&gt;

&lt;p&gt;Yes. Image Manager URLs are deterministic - the same parameters produce the same optimized image. Flutter's image cache (via &lt;code&gt;cached_network_image&lt;/code&gt; or similar packages) works normally. The CDN also caches each image variant at the edge, so the combination of client-side and edge caching provides good performance.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you test Akamai integration in CI/CD?
&lt;/h3&gt;

&lt;p&gt;The BMP SDK includes integration checks (&lt;code&gt;runIntegrationChecks()&lt;/code&gt;) that verify SDK initialization and sensor data collection on-device. For CI, mock the interceptor chain to test URL filtering and retry logic without Akamai's edge servers. A staging environment with Akamai enabled is necessary for end-to-end verification.&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
    <item>
      <title>Local AI &amp; LLM for Developers: Freelancer Verdict (2026)</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Sat, 04 Jul 2026 12:50:15 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/local-ai-llm-for-developers-freelancer-verdict-2026-3j1j</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/local-ai-llm-for-developers-freelancer-verdict-2026-3j1j</guid>
      <description>&lt;p&gt;&lt;em&gt;For freelancers, CTOs, and tech leads&lt;/em&gt; · Based on real conversations with developers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I asked freelance developers in Germany how they use local AI. The responses split into four camps — and the strongest argument wasn't the one I expected.&lt;/p&gt;




&lt;h2&gt;
  
  
  3,000 € in hardware or 90 € a month?
&lt;/h2&gt;

&lt;p&gt;Local AI. Self-hosted models. Running your own LLMs without sending data to external servers. In every developer circle, the same topic keeps surfacing.&lt;/p&gt;

&lt;p&gt;The arguments sound good. Data privacy. No recurring API costs. Independence from providers. But does the math work out? Are local LLM models competitive?&lt;/p&gt;

&lt;p&gt;I didn't want to answer this theoretically. So I asked freelance developers who work with AI daily and make real money based on their tooling decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four developer types, four perspectives
&lt;/h2&gt;

&lt;p&gt;The responses split into four positions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The privacy pragmatist
&lt;/h3&gt;

&lt;p&gt;One developer put it plainly: parts of their work are covered by NDAs. Cloud AI isn't an option because their professional liability insurance doesn't cover data sent to external APIs. Local models aren't an experiment for them. They're a necessity.&lt;/p&gt;

&lt;p&gt;That's the strongest use case for local AI. Not because the models are better, but because they're the only option. And for most tasks, the quality is sufficient.&lt;/p&gt;

&lt;p&gt;If you're processing data under GDPR constraints or working in regulated industries — especially in the DACH region, where enforcement is strictest — you face the same decision. It stops being about "is it worth it?" and becomes "how do I set it up?". (I wrote about how I handle GDPR requirements in development &lt;a href="https://khal.it/blog/ich-bin-kein-dsgvo-experte" rel="noopener noreferrer"&gt;in this post&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(If this is your situation — &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;book a free intro call&lt;/a&gt;, I'll give you an assessment of what's feasible locally.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The ROI calculator
&lt;/h3&gt;

&lt;p&gt;Counter-position, equally clear: as long as Claude Max costs 90 €/month, there's little reason to switch to local models. For 90 € you get frontier models that no local setup can touch. No hardware investment, no maintenance, no SSD management.&lt;/p&gt;

&lt;p&gt;One participant summed it up: local AI is a fun toy that developers dress up as a business expense.&lt;/p&gt;

&lt;p&gt;Harsh. But hard to argue with. The quality gap between Claude, GPT, Gemini and what you can run locally is real. Not at any economically sensible price point.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hardware tinkerer
&lt;/h3&gt;

&lt;p&gt;Then there are those who invested anyway. One developer configured a Framework Desktop with 128 GB of unified memory and installed Arch Linux. He runs llama.cpp on it. Delivered from Taiwan in five business days, quiet, surprisingly capable.&lt;/p&gt;

&lt;p&gt;The setup costs roughly 3,000 € net. The 2 TB SSD fills up fast because each larger model takes 50 to 100 GB. A second SSD is possible, but prices have gone up. He wishes he'd gotten the larger one from the start.&lt;/p&gt;

&lt;p&gt;What I liked about the conversation: he openly admits the tinkering instinct was a factor. And that the purchase decision got rationalized as "business" after the fact. Still, for someone who regularly works with local models or handles NDA-bound work, it makes sense. The quick availability of unified memory at that capacity was what sold him.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hybrid strategist
&lt;/h3&gt;

&lt;p&gt;Not everything needs a large model. One developer runs Qwen 2.5 on a MacBook Pro with 16 GB RAM. Good enough for sub-agents that create DTOs or generate boilerplate. He checks the output with DeepSeek V4 Flash. Only tasks that genuinely need power go through the cloud API.&lt;/p&gt;

&lt;p&gt;Another developer gets roughly 40 tokens per second with llama.cpp on an M1 Max with 64 GB RAM. Not as snappy as cloud, but you can get real work done with it.&lt;/p&gt;

&lt;p&gt;I find this the most interesting approach — and I'm testing it myself. On a recent project handling sensitive health data, I used Qwen 3 locally for code generation and only sent architecture questions through the cloud API. The separation worked because simple tasks stayed local while only complex reasoning went to the cloud. I wrote about how I use AI in &lt;a href="https://khal.it/blog/app-entwickeln-mit-ki" rel="noopener noreferrer"&gt;my daily app development work&lt;/a&gt; separately. Long-term, I think most developers will end up with a hybrid setup. But today it's still more vision than standard practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hardware do you need for local AI?
&lt;/h2&gt;

&lt;p&gt;If you're interested in running a local LLM, here's what came out of these conversations.&lt;/p&gt;

&lt;p&gt;Available VRAM or unified memory is the deciding factor for local inference. Not the GPU, not the CPU cores. Models need to fit entirely in memory, and Apple Silicon shares memory between CPU and GPU. Big advantage over traditional PC setups with discrete GPUs.&lt;/p&gt;

&lt;p&gt;What developers actually use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Tokens/s&lt;/th&gt;
&lt;th&gt;Price (approx.)&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MacBook Pro M5&lt;/td&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;usable for small models&lt;/td&gt;
&lt;td&gt;from 2,000 €&lt;/td&gt;
&lt;td&gt;Entry-level, sub-agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MacBook Pro M1 Max&lt;/td&gt;
&lt;td&gt;64 GB&lt;/td&gt;
&lt;td&gt;~40 tok/s&lt;/td&gt;
&lt;td&gt;from 2,500 € (used)&lt;/td&gt;
&lt;td&gt;Solid local setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework Desktop&lt;/td&gt;
&lt;td&gt;128 GB&lt;/td&gt;
&lt;td&gt;comfortable&lt;/td&gt;
&lt;td&gt;~3,000 € net&lt;/td&gt;
&lt;td&gt;All-in on local&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;2 TB SSD sounds like a lot but fills up fast. Models take 50 to 100 GB each, and you'll want to try several.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local LLM models: which ones actually work?
&lt;/h2&gt;

&lt;p&gt;Not a benchmark table. What developers are actually using:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Min. RAM&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Qwen 2.5 / Qwen 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sub-agents, DTOs, boilerplate&lt;/td&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;Best entry point for local AI coding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DeepSeek V4 Flash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Output checker, second opinion&lt;/td&gt;
&lt;td&gt;32 GB&lt;/td&gt;
&lt;td&gt;Good as a "review" model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Llama variants (llama.cpp)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All-round on Linux&lt;/td&gt;
&lt;td&gt;32–64 GB&lt;/td&gt;
&lt;td&gt;Large community, regular updates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Getting started with local AI
&lt;/h2&gt;

&lt;p&gt;Want to try it? The fastest path is &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. Install it, run &lt;code&gt;ollama run qwen2.5&lt;/code&gt;, done. No Docker, no Python setup. If you want more control, go with llama.cpp — more setup required, but more flexibility with models and parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running LLMs locally: does the math work?
&lt;/h2&gt;

&lt;p&gt;The numbers behind what the ROI calculator already sensed:&lt;/p&gt;

&lt;p&gt;A local setup with a Framework Desktop and 128 GB costs about 3,000 € plus electricity (roughly 30 €/month). Add time for setup, maintenance, and model updates.&lt;/p&gt;

&lt;p&gt;Claude Max runs 90 €/month, so 1,080 €/year. No maintenance, frontier quality, immediately ready to use. The API is variable, 20 to 200 €/month depending on volume.&lt;/p&gt;

&lt;p&gt;Even if you replace cloud costs entirely with local AI — unrealistic because local models don't match frontier quality — it takes close to three years to recoup the hardware. Without counting the time investment. (Curious what an AI-assisted app project costs overall? Here's &lt;a href="https://khal.it/blog/was-kostet-eine-app" rel="noopener noreferrer"&gt;my breakdown of app development costs&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Local AI doesn't replace cloud. It supplements. And whether that supplement is worth it comes down to one thing: do you need to keep data local?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this could change soon
&lt;/h2&gt;

&lt;p&gt;What I keep thinking about: the subsidy era won't last forever. Claude Max at 90 €/month is an incredible deal. At some point prices will rise or usage gets more limited. Developers who get familiar with local AI now will be ready when the economics shift.&lt;/p&gt;

&lt;p&gt;Local models are also catching up fast. Qwen 3 is significantly better than Qwen 2.5, and the next generation is already in the works. Once local models reach 80% of cloud quality — and for simple tasks, they already do — the calculus tips.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;For most freelancers and developers in 2026, cloud AI is the better choice. The models are better, the costs are manageable.&lt;/p&gt;

&lt;p&gt;The exception: NDA-bound projects. If you can't send client data to the cloud, local models aren't optional. They're necessary. And the quality is good enough.&lt;/p&gt;

&lt;p&gt;My recommendation: don't buy dedicated hardware yet. But install Ollama, run &lt;code&gt;ollama run qwen2.5&lt;/code&gt; on your existing machine, and see what's possible. Getting started costs nothing but half an hour.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Figuring out which AI setup fits your project — local, cloud, or hybrid? &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;Book a free intro call&lt;/a&gt; — I'll give you an honest assessment of what makes sense for your setup. More about my approach on the &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;app development page&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>freelancing</category>
    </item>
    <item>
      <title>Embedding a Website in Your App - Why It Is More Complex Than You Think</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Fri, 03 Jul 2026 09:41:14 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/embedding-a-website-in-your-app-why-it-is-more-complex-than-you-think-f1m</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/embedding-a-website-in-your-app-why-it-is-more-complex-than-you-think-f1m</guid>
      <description>&lt;h1&gt;
  
  
  Embedding a Website in Your App -- Why It Is More Complex Than You Think
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Embedding WebViews in apps seems easy -- take an existing website and drop it in. In practice, there is hidden complexity behind it: syncing authentication, managing navigation between web and native, building JavaScript bridges. Using a real e-commerce project with Shopify infrastructure, this article shows where the problems are and when WebViews are the right choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Question
&lt;/h2&gt;

&lt;p&gt;"Can't we just embed our website in the app?"&lt;/p&gt;

&lt;p&gt;Every mobile developer hears this at some point. It sounds logical: a working online shop already exists, with curated product pages, a functional checkout, and integrated search. Why rebuild everything natively when embedding would be easier?&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;WebView&lt;/strong&gt; is a browser component inside a native app -- essentially an embedded browser without the address bar. According to &lt;a href="https://www.appbrain.com/stats/libraries/details/webview/android-webview" rel="noopener noreferrer"&gt;AppBrain (2025)&lt;/a&gt;, over 60% of Android applications use some form of WebView technology.&lt;/p&gt;

&lt;p&gt;The answer: technically possible, but "easy" it is not.&lt;/p&gt;

&lt;p&gt;This article walks through a real project -- a jewelry e-commerce brand with a Shopify backend -- to show what hides behind that single line of code. No theory, just real code examples, real bugs, and real workarounds.&lt;/p&gt;

&lt;p&gt;The app is a Flutter implementation with a hybrid strategy: some screens are built natively, others use WebViews. The problems appear exactly at the boundaries between these two worlds.&lt;/p&gt;

&lt;p&gt;This is not an argument against WebViews. It is an argument for informed planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid Architecture: What Goes Native, What Goes WebView?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built natively:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication (login, registration)&lt;/li&gt;
&lt;li&gt;Onboarding&lt;/li&gt;
&lt;li&gt;Home screen with product highlights&lt;/li&gt;
&lt;li&gt;Account management&lt;/li&gt;
&lt;li&gt;Settings &amp;amp; preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;WebView:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shop browsing (collections, categories)&lt;/li&gt;
&lt;li&gt;Product details&lt;/li&gt;
&lt;li&gt;Cart&lt;/li&gt;
&lt;li&gt;Checkout&lt;/li&gt;
&lt;li&gt;Search&lt;/li&gt;
&lt;li&gt;Order history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three reasons for this split:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CMS flexibility&lt;/strong&gt;: Product pages and collections change constantly. The marketing team manages them through Shopify -- building them natively would mean manually mirroring every CMS change in the app.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PCI Compliance&lt;/strong&gt;: Building checkout natively requires your own PCI compliance certification. Hosted solutions like Shopify Checkout handle that for you.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost-benefit&lt;/strong&gt;: Not every screen justifies native implementation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shop page looks deceptively simple in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;WebviewPageWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;initialUrl:&lt;/span&gt; &lt;span class="n"&gt;shopConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Behind that one line: a 476-line container, five plugins with over 800 lines of code, a JavaScript bridge, cookie management, platform-specific navigation workarounds, and a state machine for the initial page load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complexity #1: Auth Sync
&lt;/h2&gt;

&lt;p&gt;The first major challenge in hybrid apps: credentials live in two separate worlds.&lt;/p&gt;

&lt;p&gt;Login happens natively. The app communicates directly with the Shopify Storefront API, stores tokens securely via &lt;code&gt;flutter_secure_storage&lt;/code&gt;, and manages auth state through a central controller.&lt;/p&gt;

&lt;p&gt;Then the user wants to see their cart. The cart is a WebView. The WebView initially knows nothing about the login status.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tokens to Cookies
&lt;/h3&gt;

&lt;p&gt;Solution: a &lt;code&gt;TokenBag&lt;/code&gt; model converts native tokens into WebView-compatible cookies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenBag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;cartId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;shopifyCustomerAccessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;cartToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cartId&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cartToken&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s"&gt;'cart'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cartToken&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shopifyCustomerAccessToken&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="s"&gt;'shopifyCustomerAccessToken'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shopifyCustomerAccessToken&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds manageable. But setting these cookies is complex. Every auth state change requires: removing existing session cookies, setting new cookies, and setting additional app-specific cookies.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Nuclear Option: Destroying the WebView Entirely
&lt;/h3&gt;

&lt;p&gt;Sometimes changing cookies is not enough. The entire WebView has to be destroyed and rebuilt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;rebuildKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ValueKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenBag&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shopifyCustomerAccessToken&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'guest'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="n"&gt;rebuildIndex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;InAppWebView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;key:&lt;/span&gt; &lt;span class="n"&gt;rebuildKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flutter sees the new key, discards the old WebView completely, and creates a new one. Including a fresh page load, a new JavaScript context, new cookie sessions. No graceful transition -- a hard reset.&lt;/p&gt;

&lt;h3&gt;
  
  
  Web-Initiated Native Login
&lt;/h3&gt;

&lt;p&gt;There is also the other direction: the WebView can trigger a native login. When a user clicks "Sign in" during the web checkout, a JavaScript bridge function opens a native login bottom sheet. After native login, the app updates the auth state, refreshes cookies, and the WebView should reflect that.&lt;/p&gt;

&lt;p&gt;One git branch is literally called &lt;code&gt;fix/SHOP-74-login-in-cart&lt;/code&gt;. Not login in general -- login &lt;em&gt;from inside the cart&lt;/em&gt;. That is how specific the edge cases get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complexity #2: Navigation
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;shouldOverrideUrlLoading&lt;/code&gt; method in the WebView container spans 100 lines of platform-specific logic. Every line exists for a reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  State Machine for Initial Requests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;InitialRequestState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because &lt;code&gt;shouldOverrideUrlLoading&lt;/code&gt; intercepts &lt;em&gt;every&lt;/em&gt; URL request -- including the initial one that loads the start page. Without this state machine, the app would block its own initial page load.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS vs. Android: Fundamentally Different Event Models
&lt;/h3&gt;

&lt;p&gt;iOS sends &lt;code&gt;NavigationType.BACK_FORWARD&lt;/code&gt; for back navigation. Android? Does not fire &lt;code&gt;shouldOverrideUrlLoading&lt;/code&gt; during &lt;code&gt;goBack()&lt;/code&gt;. Android marks redirects explicitly via an &lt;code&gt;isRedirect&lt;/code&gt; flag. iOS does not have this flag.&lt;/p&gt;

&lt;p&gt;Every condition in the code exists because a specific bug surfaced on a specific platform. This is not over-engineering -- this is damage control.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;restartWebView()&lt;/code&gt; Workaround
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TODO(khalit): this feels like a hack. calling restartWebView is needed&lt;/span&gt;
&lt;span class="c1"&gt;// because it prevents shouldOverrideUrl from being called, which would&lt;/span&gt;
&lt;span class="c1"&gt;// cause unexpected behavior.&lt;/span&gt;
&lt;span class="n"&gt;restartWebView&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;urlRequest:&lt;/span&gt; &lt;span class="n"&gt;URLRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;url:&lt;/span&gt; &lt;span class="n"&gt;WebUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullUrl&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And back navigation with an artificial delay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;Durations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extralong1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 700ms&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waiting 700 milliseconds because an immediate WebView reload would load the previous page instead of the new one. The kind of fix that never shows up in tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complexity #3: Five Plugins, Two Worlds, One &lt;code&gt;window.mobileApp&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most elegant -- and simultaneously most demanding -- solution in the project is the JavaScript bridge. It connects the native app with the website through a plugin system.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Five Plugins
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ConsentPlugin&lt;/td&gt;
&lt;td&gt;298&lt;/td&gt;
&lt;td&gt;Pass consent data to the WebView&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrackingPlugin&lt;/td&gt;
&lt;td&gt;268&lt;/td&gt;
&lt;td&gt;Translate web analytics to Firebase Analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NavigatePlugin&lt;/td&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;td&gt;Handle web-side navigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LoginPlugin&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;Web triggers native login flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UpdateCartPlugin&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;Web notifies app about cart changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~816&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript bridge infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;816 lines of plugin code. Plus the 476-line container. For "just embed it."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Consent Complexity: Three Approaches at Once
&lt;/h3&gt;

&lt;p&gt;The ConsentPlugin uses &lt;em&gt;three&lt;/em&gt; simultaneous approaches to make sure the website recognizes the consent state:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 1: URL parameters&lt;/strong&gt; -- Inject consent data directly into the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 2: JavaScript injection at DOCUMENT_START&lt;/strong&gt; -- Parse URL parameters during page load and set them globally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 3: &lt;code&gt;evaluateJavascript&lt;/code&gt; after load&lt;/strong&gt; -- Send consent data again via JS execution.&lt;/p&gt;

&lt;p&gt;Three approaches for &lt;em&gt;one&lt;/em&gt; piece of information. Why? Because none of them is reliable enough on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;URL parameters can disappear during client-side redirects&lt;/li&gt;
&lt;li&gt;JavaScript injection fires too early for certain Shopify scripts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;evaluateJavascript&lt;/code&gt; fires too late for the initial render&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So all three together. Full redundancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complexity #4: Details That Keep You Up at Night
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. User-Agent Spoofing
&lt;/h3&gt;

&lt;p&gt;Shopify returns 403 errors when it detects default WebView user agents. The solution: hardcoded browser user agents per platform.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;userAgent:&lt;/span&gt; &lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isIOS&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '&lt;/span&gt;
      &lt;span class="s"&gt;'AppleWebKit/605.1.15 ...'&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Mozilla/5.0 (Linux; Android 13; Pixel 7) '&lt;/span&gt;
      &lt;span class="s"&gt;'AppleWebKit/537.36 ...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, hardcoded strings. Yes, they can go stale. Yes, manual updates are required.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Custom WebView SDK Fork
&lt;/h3&gt;

&lt;p&gt;The app uses a &lt;strong&gt;custom fork&lt;/strong&gt; of &lt;code&gt;flutter_inappwebview&lt;/code&gt;. Why? The standard version lacks Android Payment Request support -- needed for Google Pay in checkout.&lt;/p&gt;

&lt;p&gt;That means: maintaining your own fork, watching upstream changes, checking compatibility on every Flutter update. For a single missing feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Loading State: Render Invisibly, Then Show
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Step 1: Render invisibly&lt;/span&gt;
&lt;span class="n"&gt;Opacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;opacity:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: Measure height (after load)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evaluateJavascript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kn"&gt;source&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Math.max(document.body.scrollHeight, '&lt;/span&gt;
          &lt;span class="s"&gt;'document.documentElement.scrollHeight)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;contentHeight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toDouble&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: Show with measured height&lt;/span&gt;
&lt;span class="n"&gt;SizedBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;height:&lt;/span&gt; &lt;span class="n"&gt;contentHeight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The Abandoned Native Approach
&lt;/h3&gt;

&lt;p&gt;Deep in the codebase lies commented-out code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;//   ref&lt;/span&gt;
&lt;span class="c1"&gt;//       .read(shopifyCartControllerProvider.notifier)&lt;/span&gt;
&lt;span class="c1"&gt;//       .addProductToCart(&lt;/span&gt;
&lt;span class="c1"&gt;//         merchandiseId: selectedVariant.id,&lt;/span&gt;
&lt;span class="c1"&gt;//         product: product,&lt;/span&gt;
&lt;span class="c1"&gt;//         variant: selectedVariant.title,&lt;/span&gt;
&lt;span class="c1"&gt;//       );&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attempt at implementing cart functionality natively. Tried and abandoned -- because syncing between native cart state and web cart state was too fragile.&lt;/p&gt;

&lt;h2&gt;
  
  
  When WebViews Are the Right Choice
&lt;/h2&gt;

&lt;p&gt;After all this complexity, it would be unfair to write off WebViews entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Product Pages &amp;amp; Collections
&lt;/h3&gt;

&lt;p&gt;CMS-managed content that changes weekly? Definitely WebView. The marketing team maintains it through Shopify, and the app shows it instantly -- no app updates needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Checkout
&lt;/h3&gt;

&lt;p&gt;PCI Compliance, payment provider integration, Shop Pay, Apple Pay, Google Pay, Klarna, PayPal -- implementing all of that natively is a project in itself. The hosted Shopify checkout handles everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Search
&lt;/h3&gt;

&lt;p&gt;Faceted filtering, autocomplete, search suggestions -- all Shopify strengths with little to gain from a native rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Legal Pages
&lt;/h3&gt;

&lt;p&gt;Terms of service, privacy policy, legal notice -- open in an external browser. No WebView, no custom Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Decision Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Lean native&lt;/th&gt;
&lt;th&gt;Lean WebView&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth/Login&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment/Checkout&lt;/td&gt;
&lt;td&gt;Only if PCI-compliant&lt;/td&gt;
&lt;td&gt;Yes (Hosted Solution)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMS Content&lt;/td&gt;
&lt;td&gt;For infrequent updates&lt;/td&gt;
&lt;td&gt;For frequent updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navigation-critical&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Use with caution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance-critical&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline capability&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;"Can't we just embed the website?"&lt;/p&gt;

&lt;p&gt;Yes, but.&lt;/p&gt;

&lt;p&gt;WebViews are a tool, not a shortcut. They are appropriate -- for CMS content, hosted checkouts, anywhere web infrastructure is better than what native can deliver in the available time.&lt;/p&gt;

&lt;p&gt;But they come with their own complexity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credentials need to be synced between separate systems&lt;/li&gt;
&lt;li&gt;Navigation requires platform-specific handling for iOS and Android&lt;/li&gt;
&lt;li&gt;JavaScript bridges need to be built, tested, and maintained&lt;/li&gt;
&lt;li&gt;Edge cases (user agents, SSL, loading states) eat up time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In numbers: this project needed &lt;strong&gt;476 lines&lt;/strong&gt; for the WebView container, &lt;strong&gt;816 lines&lt;/strong&gt; for plugin code, and countless bugfix commits -- for something that started with "just embed it."&lt;/p&gt;

&lt;p&gt;The right question is not "embed or build native?" but rather: &lt;strong&gt;"Which screens native, which WebView, and how do they communicate?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you answer that early in the project -- and know about the hidden complexity -- you will make better architecture decisions and avoid budget surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a WebView in mobile apps?
&lt;/h3&gt;

&lt;p&gt;A WebView is an embedded browser component in native apps. It renders web content directly -- without a separate browser. Users do not see an address bar and ideally do not notice they are looking at websites.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are WebView apps cheaper than native apps?
&lt;/h3&gt;

&lt;p&gt;Initially, often yes -- avoiding native reimplementation of existing websites saves money. Long-term, the hidden complexity (auth sync, navigation workarounds, JavaScript bridges) can eat into that cost advantage. This project has 1,290+ lines just for WebView infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Apple accept WebView apps in the App Store?
&lt;/h3&gt;

&lt;p&gt;Apple rejects apps that are &lt;em&gt;exclusively&lt;/em&gt; WebView-based (Guideline 4.2 -- Minimum Functionality). Hybrid apps with native features plus WebViews are accepted -- as long as they offer advantages over mobile websites.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should you build native instead of WebView?
&lt;/h3&gt;

&lt;p&gt;Rule of thumb: go native for (a) auth, (b) offline requirements, (c) performance-critical functionality, (d) complex native gestures and animations. Use WebView for CMS-managed content, hosted checkouts, and frequently updated screens without native performance requirements.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
    </item>
    <item>
      <title>App Engagement Strategy: Fixing a 0.6% Push Open Rate</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Thu, 02 Jul 2026 16:19:58 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/mobile-app-engagement-strategy-how-we-fixed-a-06-push-notification-open-rate-2iln</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/mobile-app-engagement-strategy-how-we-fixed-a-06-push-notification-open-rate-2iln</guid>
      <description>&lt;p&gt;&lt;em&gt;Based on my talk at &lt;a href="https://www.meetup.com/flutter-berlin/" rel="noopener noreferrer"&gt;Flutter Berlin&lt;/a&gt; (Flutter OctoberFest). Assumes familiarity with mobile development. No ML or data science background needed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;📺 &lt;a href="https://www.youtube.com/watch?v=XNFLwyOlZ6w" rel="noopener noreferrer"&gt;Watch the full talk on YouTube&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Campaign push notifications at MediaMarktSaturn had a 0.6% open rate across 2.7 million sends. The problem wasn't the content. It was timing, relevance, and volume. We found a framework called Just-In-Time Adaptive Interventions (JITAI), originally from medical health apps, and designed a system that decides &lt;em&gt;whether&lt;/em&gt;, &lt;em&gt;when&lt;/em&gt;, and &lt;em&gt;what&lt;/em&gt; to send each user. The system was still in discovery when I gave this talk, but the thinking behind it applies to any mobile app engagement strategy.&lt;/p&gt;




&lt;p&gt;Most mobile app engagement strategies treat push notifications as a broadcast channel: pick a segment, write copy, blast it out, hope for clicks. This post shows what happens when that stops working at scale, and how a research-backed framework gives you a better way to improve app engagement through push notifications. The examples come from a real e-commerce app with millions of users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that started the conversation
&lt;/h2&gt;

&lt;p&gt;At MediaMarktSaturn, I worked on the consumer apps team, one of the larger Flutter teams in Germany. We maintained apps across 13 countries. The quarter before, we'd also shipped a push notification service.&lt;/p&gt;

&lt;p&gt;Here's what a bad campaign looked like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Number&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications sent&lt;/td&gt;
&lt;td&gt;2,700,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opened&lt;/td&gt;
&lt;td&gt;~17,000 (0.6%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converted (from opens)&lt;/td&gt;
&lt;td&gt;66 (0.36%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 66 purchases from 2.7 million interruptions. Not 66 thousand. Sixty-six.&lt;/p&gt;

&lt;p&gt;This wasn't even our worst campaign. But it made the question impossible to dodge: are we providing value to our users, or are we just making noise?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why push notifications fail to drive app engagement
&lt;/h2&gt;

&lt;p&gt;We kept coming back to four problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timing.&lt;/strong&gt; Send a notification while someone is in a meeting or on the train and it's just a disturbance. Every user has different windows of attention throughout the day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relevance.&lt;/strong&gt; A promotion for kitchen appliances is useful to someone furnishing a new apartment. To everyone else, it's clutter. Batch campaigns don't distinguish between the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume.&lt;/strong&gt; Send too many and users go numb. Or they revoke notification permissions entirely. Once that happens, the channel is dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Competition.&lt;/strong&gt; The average user has around 50 apps installed. Each one wants a slice of a limited attention budget. If your notification doesn't earn its place, it gets swiped away.&lt;/p&gt;

&lt;p&gt;All four come back to the same thing: &lt;strong&gt;receptivity&lt;/strong&gt;, the user's ability and willingness, right now, to receive and act on an interruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  What receptivity actually means
&lt;/h2&gt;

&lt;p&gt;Receptivity isn't a toggle. It shifts throughout the day based on two kinds of signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Internal:&lt;/strong&gt; mood, cognitive load, what the user is focused on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External:&lt;/strong&gt; location, time of day, whether they're driving, walking, or sitting still&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is simple: if someone isn't in a position to care about your message, sending it does nothing at best and annoys them at worst. Push to a non-receptive user often enough and they'll opt out.&lt;/p&gt;

&lt;p&gt;Our bet: if we time notifications better and make them personal, users will see them as useful instead of annoying. Useful notifications get opened. Opened notifications convert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is JITAI? A framework from health research
&lt;/h2&gt;

&lt;p&gt;Just-In-Time Adaptive Interventions (JITAI) comes from medical health research, specifically addiction support and weight loss apps. Those systems monitor a user's internal and external state and only step in when two conditions are met:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user is in a &lt;strong&gt;vulnerable state&lt;/strong&gt; (in a health app: at risk of relapsing; for us: likely to benefit from a product notification)&lt;/li&gt;
&lt;li&gt;The user is in a &lt;strong&gt;receptive state&lt;/strong&gt; (able and willing to process the message right now)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The framework has four parts:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fjitai-framework-pipeline.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fjitai-framework-pipeline.svg" alt="The JITAI framework pipeline: decision point, tailoring variables, intervention options, proximal outcomes, and distal outcome — with a decision maker orchestrating the flow" width="900" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Decision points
&lt;/h3&gt;

&lt;p&gt;The moment the system decides whether to show a notification. For server-sent push, this is when the server would normally fire. For on-device local notifications, you can set intervals and check every X minutes whether conditions are right.&lt;/p&gt;

&lt;p&gt;The important part: the decision isn't just "send." It's "send, delay, or don't send at all."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tailoring variables
&lt;/h3&gt;

&lt;p&gt;The data that feeds the decision. Two sources:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passive&lt;/strong&gt; (no user action needed):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device type (Android and iOS users often have different notification habits)&lt;/li&gt;
&lt;li&gt;Location (is the user near a physical store?)&lt;/li&gt;
&lt;li&gt;Time patterns (when does this user typically open the app?)&lt;/li&gt;
&lt;li&gt;Sensor data (accelerometer, ambient light, with consent)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Active&lt;/strong&gt; (user tells us directly):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wishlist items&lt;/li&gt;
&lt;li&gt;Search history&lt;/li&gt;
&lt;li&gt;Category preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system has to work with whatever the user consents to share. Someone who only grants location access should still get a noticeably better experience than a batch campaign.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Intervention options
&lt;/h3&gt;

&lt;p&gt;What the system can actually do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Information notification&lt;/td&gt;
&lt;td&gt;New arrivals, restocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promotional notification&lt;/td&gt;
&lt;td&gt;Sales, discounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location-based notification&lt;/td&gt;
&lt;td&gt;Store-specific offers when nearby&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich push&lt;/td&gt;
&lt;td&gt;Image-heavy, interactive notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reschedule&lt;/td&gt;
&lt;td&gt;Delay the notification for a better time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No push&lt;/td&gt;
&lt;td&gt;Suppress the notification entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row matters the most. Sometimes the best notification is none.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The decision maker
&lt;/h3&gt;

&lt;p&gt;This is the core. It takes the tailoring variables and intervention options and decides &lt;em&gt;which&lt;/em&gt; intervention to offer, to &lt;em&gt;whom&lt;/em&gt;, and &lt;em&gt;when&lt;/em&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Algorithmic:&lt;/strong&gt; Decision trees and rules. Easier to build, test, and explain. Example: if the user is within 500m of a store AND has a wishlist item on sale at that store, send a location-based push.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine learning:&lt;/strong&gt; An on-device reinforcement learning model that learns from each user's interaction patterns. The model improves per-user over time without sharing data across devices. Harder to build and harder to test, but it can pick up patterns that rules miss.&lt;/p&gt;

&lt;p&gt;In practice, you'd start with rules and add ML where the rules stop improving.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fjitai-reinforcement-learning.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkhal.it%2Fblog%2Fjitai-reinforcement-learning.svg" alt="Reinforcement learning decision maker: the on-device RL model receives decision points, chooses actions, and learns from user responses over time" width="640" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Push notification examples that improve app engagement
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The store walk-by
&lt;/h3&gt;

&lt;p&gt;You're walking through Alexanderplatz. Your Saturn app knows your wishlist, and you've been eyeing a pair of headphones. In the background, the system checks: does the Saturn store at Alexanderplatz have those headphones in stock? Is there a promotion running?&lt;/p&gt;

&lt;p&gt;If yes, send a push. You already wanted the product. The store is 200 meters away. The promotion gives you a reason to walk in now.&lt;/p&gt;

&lt;p&gt;You find out about a deal on something you already want. The store gets a walk-in. One notification, no spam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distal outcome:&lt;/strong&gt; purchase in store.&lt;br&gt;
&lt;strong&gt;Proximal outcome:&lt;/strong&gt; user becomes aware of the deal. Even if they don't tap the notification, they've absorbed the information.&lt;/p&gt;

&lt;h3&gt;
  
  
  The abandoned cart
&lt;/h3&gt;

&lt;p&gt;You're at home, browsing the MediaMarkt app. You add three items to your cart. Your partner walks in, you put the phone down, and the cart just sits there.&lt;/p&gt;

&lt;p&gt;The system picks up that the cart has been idle for a few hours. One of the items now has a 20% discount that expires soon. You probably forgot about the cart, and you definitely don't know about the price drop.&lt;/p&gt;

&lt;p&gt;Send a notification about the discount. You get a useful reminder. The retailer recovers a sale that would have quietly disappeared.&lt;/p&gt;

&lt;p&gt;Both examples follow the same logic: only interrupt someone when you're reasonably confident the notification is worth their attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts of this engagement strategy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Privacy and GDPR
&lt;/h3&gt;

&lt;p&gt;We're in Germany. &lt;a href="https://khal.it/blog/ich-bin-kein-dsgvo-experte" rel="noopener noreferrer"&gt;GDPR isn't a formality&lt;/a&gt;, it's the operating environment. The system touches sensitive data: location, purchase history, browsing behavior. The deal with users has to be clear: this data improves your notification experience, full stop. It's never sold or shared.&lt;/p&gt;

&lt;p&gt;If users don't trust the system, they won't grant the permissions it needs. And then there's nothing to work with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing tailoring variables
&lt;/h3&gt;

&lt;p&gt;Which variables actually predict receptivity? Device type? Time of day? Distance to a store? Some combination? This needs serious A/B testing and careful measurement. The search space is large, and gut feelings about what matters tend to be wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Incomplete data
&lt;/h3&gt;

&lt;p&gt;The system has to work when users only grant some permissions, or none at all. Someone who shares location, wishlist, and browsing history gives the decision maker plenty of signal. Someone who shares nothing gets the batch experience, same as before, no penalties.&lt;/p&gt;

&lt;p&gt;That gap in quality is inherent, and it's fine. But it needs to be measured honestly, not papered over in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;When I gave this talk at Flutter Berlin, the system was in the discovery phase. We had research, architecture, and a talk, but no production code yet. The academic literature on JITAI is solid, but applying it to e-commerce push at scale, across 13 countries and millions of users, raises problems the papers don't address.&lt;/p&gt;

&lt;p&gt;The thing I keep coming back to: the problem with push notifications isn't push notifications. It's sending the wrong message at the wrong time. And honestly, it's not having the discipline to send nothing when you don't have a good reason.&lt;/p&gt;

&lt;p&gt;Most mobile app engagement strategies still treat push as a broadcast channel. The bar for standing out is low: just stop yelling at people who didn't ask.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jchp6xjq5fy1g3xdsjk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jchp6xjq5fy1g3xdsjk.webp" alt="Flutter Berlin OctoberFest group photo at MediaMarktSaturn" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks to &lt;a href="https://www.meetup.com/flutter-berlin/" rel="noopener noreferrer"&gt;Flutter Berlin&lt;/a&gt; and MediaMarktSaturn for hosting the event.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're building a mobile app that needs smarter notifications — or rethinking the system you already have — &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;that's the kind of architecture work I do&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Push notification open rates are a symptom, not the disease.&lt;/strong&gt; The real problem is sending the wrong message at the wrong time to the wrong user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The JITAI framework gives structure to personalization.&lt;/strong&gt; Decision points, tailoring variables, intervention options, and a decision maker — four components that turn "send smarter notifications" into an engineering problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with rules, add ML later.&lt;/strong&gt; Algorithmic decision trees are easier to build, test, and explain. Machine learning picks up patterns rules miss, but only after you understand what "better" looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The best notification is sometimes no notification.&lt;/strong&gt; Suppressing a low-value push preserves the user's trust and keeps the channel alive for when it matters.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/website-in-app-einbetten-webview-komplexitaet" rel="noopener noreferrer"&gt;Embedding a Website in Your App — Why It Is More Complex Than You Think&lt;/a&gt; — the hidden complexity behind WebViews in mobile apps&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/webview-app-payment-flows-state-sync-teil-2" rel="noopener noreferrer"&gt;WebView App: Payment Flows, State Sync, and Platform Hacks (Part 2)&lt;/a&gt; — where WebView integration gets really messy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/flutter-webview-tap-gestures-break-nestedscrollview-ios-fix" rel="noopener noreferrer"&gt;Fix: Flutter WebView Tap Gestures Stop Working After Scrolling in NestedScrollView (iOS)&lt;/a&gt; — a platform bug we had to solve in production&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/the-phoenix-pattern-in-flutter" rel="noopener noreferrer"&gt;The Phoenix Pattern in Flutter: How to Restart Your App Without Restarting Your App&lt;/a&gt; — a pattern for forced app restarts without killing the process&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://khal.it/blog/klaviyo-push-notifications-flutter" rel="noopener noreferrer"&gt;Klaviyo Push Notifications in Flutter: Three Pitfalls Nobody Warns You About&lt;/a&gt; — silent delivery drops, tap collisions, and channel importance traps when adding Klaviyo to an existing FCM setup&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://doi.org/10.1007/s12160-016-9830-8" rel="noopener noreferrer"&gt;Nahum-Shani et al. — "Just-in-Time Adaptive Interventions (JITAIs) in Mobile Health"&lt;/a&gt; — the foundational JITAI paper&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://doi.org/10.1093/her/cyy054" rel="noopener noreferrer"&gt;Hardeman et al. — "Developing and testing a digital intervention for health behavior change"&lt;/a&gt; — applying adaptive interventions in practice&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://firebase.google.com/docs/cloud-messaging" rel="noopener noreferrer"&gt;Firebase Cloud Messaging documentation&lt;/a&gt; — push notification infrastructure most Flutter apps use&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://gdpr.eu/cookies/" rel="noopener noreferrer"&gt;GDPR and push notifications&lt;/a&gt; — EU regulations on user data in notification targeting&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>Building Apps with AI: What Actually Works (2026)</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Thu, 02 Jul 2026 16:19:57 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/building-apps-with-ai-what-actually-works-2026-goh</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/building-apps-with-ai-what-actually-works-2026-goh</guid>
      <description>&lt;p&gt;&lt;em&gt;For CTOs, founders, and developers who want to know how AI is actually changing app development. No buzzword bingo — experiences from real projects.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; "Build an app with AI" can mean two things: using AI tools to develop faster, or adding AI features to your app. I do both daily. The reality: AI coding tools like Claude Code speed up my work by 20–40%, but they don't replace architecture decisions or testing. No-code AI builders like Bolt or FlutterFlow produce decent prototypes, but not production-ready apps. And AI features in apps (chatbots, image recognition, recommendations) are now affordable — if you know which API to use for which problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "build an app with AI" actually means
&lt;/h2&gt;

&lt;p&gt;When clients mention "AI" in their initial inquiry, they usually mean one of two things.&lt;/p&gt;

&lt;p&gt;The first: using AI tools to make development itself faster. Claude Code implements entire features from a description, GitHub Copilot suggests inline completions, Cursor navigates the codebase. This affects me as a developer and changes how I work.&lt;/p&gt;

&lt;p&gt;The second: adding AI features to the app itself. A chatbot that answers customer questions. Image recognition that identifies products. Personalized recommendations based on user behavior. This affects the product and its features.&lt;/p&gt;

&lt;p&gt;Both meanings matter, but they're fundamentally different. I'll cover both.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI as a development tool: what works, what doesn't
&lt;/h2&gt;

&lt;p&gt;I've been using AI coding tools in my daily work since 2023. Currently it's exclusively Claude Code — as a coding partner that knows the entire codebase and can implement features end-to-end.&lt;/p&gt;

&lt;p&gt;What genuinely goes faster: boilerplate code, test cases, data models, regular expressions, and navigating unfamiliar codebases. When I need a new Flutter widget that follows an existing pattern in the app, Claude Code builds it in seconds instead of minutes. Unit tests for a method? Claude Code generates them in the context of the existing test suite and gets the approach right most of the time.&lt;/p&gt;

&lt;p&gt;On a recent project — an e-commerce app with a complex shopping cart — I estimate I spent 30% less time on implementation than I would have without AI tools. The gain isn't in single big moments but in hundreds of small time savings spread across the entire project.&lt;/p&gt;

&lt;p&gt;What doesn't work: architecture decisions. "Should we use Riverpod or Bloc for state management?" — AI will give you an answer, but whether it's right for your specific project requires someone who understands the context. (More on framework choices in my &lt;a href="https://khal.it/en/blog/flutter-vs-react-native-2026" rel="noopener noreferrer"&gt;Flutter vs React Native comparison&lt;/a&gt;.) Same goes for security decisions, performance optimization, and anything where context extends beyond a single file.&lt;/p&gt;

&lt;p&gt;One point that rarely gets mentioned: AI-generated code needs the same careful review as human-written code. I've accepted AI suggestions that compiled and passed tests but were logically wrong. That happens when you stop reading the output. AI tools make an experienced developer faster. They don't make an inexperienced developer experienced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding AI features to your app: the practical options
&lt;/h2&gt;

&lt;p&gt;The second meaning — putting AI into the product. A lot has changed here in the last two years.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chatbots and text processing
&lt;/h3&gt;

&lt;p&gt;For chatbots and text processing, the large language models are the obvious choice. Claude API, OpenAI API, or Google Gemini as the backend. The integration is technically straightforward: API call to the provider, display the response. The challenge is in prompt design (what instructions does the model get?), cost control (API calls charge per token), and making sure the responses actually make sense for your context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image recognition and computer vision
&lt;/h3&gt;

&lt;p&gt;For image recognition and computer vision, there are on-device options: TensorFlow Lite, Core ML (iOS), and Google's ML Kit. The advantage: processing happens on the device with no server costs and no latency. A concrete example: for a health app, I built text recognition using Google ML Kit that scans product codes from leaflet inserts. The user points their camera, the app recognizes the code via on-device OCR and unlocks content — no server costs, no latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recommendation systems
&lt;/h3&gt;

&lt;p&gt;Recommendation systems — "products you might like" — can now be built with services like AWS Personalize or Google Recommendations AI without training your own ML model. Costs start low and scale with usage. In practice, I haven't needed to train a custom recommendation algorithm on any project so far — the cloud services cover most use cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speech recognition
&lt;/h3&gt;

&lt;p&gt;Speech recognition runs on native platform APIs from Apple and Google — reliable and without server costs. Once you need intent detection or context across multiple sentences, you're back to the LLM APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each option costs
&lt;/h3&gt;

&lt;p&gt;What each option costs depends heavily on usage volume. For an MVP with a few hundred users, API costs run 10–50 €/month. With hundreds of thousands of users, it can quickly hit four figures. On-device ML has no ongoing costs — but the initial development is more involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  No-code AI: building apps without programming
&lt;/h2&gt;

&lt;p&gt;Bolt, Lovable, FlutterFlow with AI assistant — I've tested them all. These tools promise to generate apps from text descriptions.&lt;/p&gt;

&lt;p&gt;I've tested several of them. My honest take:&lt;/p&gt;

&lt;p&gt;For prototypes and demos, they work surprisingly well. Bolt generates a functional web app from a prompt — with UI, navigation, and basic logic. FlutterFlow creates Flutter UIs from descriptions that actually look decent. For a pitch deck prototype or an internal demo, that saves days.&lt;/p&gt;

&lt;p&gt;For production-ready apps, they fall short. The codebase is hard to maintain, performance sits below what an experienced developer produces, and the moment you need something beyond standard patterns, you hit walls. Error handling, edge cases, accessibility, performance optimization — these are the things that separate a good app from a mediocre one, and no builder handles them well.&lt;/p&gt;

&lt;p&gt;My recommendation: use no-code AI for validation. Build a prototype, show it to potential users, collect feedback. If validation is positive, invest in a professional implementation. The prototype then provides valuable requirements for the developer. (More on this in my &lt;a href="https://khal.it/en/blog/app-programmieren-lernen" rel="noopener noreferrer"&gt;post for founders building their first app&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Want to know what an MVP with AI features actually costs? &lt;a href="https://khal.it/en/blog/was-kostet-eine-app" rel="noopener noreferrer"&gt;Here's my honest breakdown&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI means for costs and timelines
&lt;/h2&gt;

&lt;p&gt;The honest answer: AI makes app development cheaper and faster, but not to the degree some people promise.&lt;/p&gt;

&lt;p&gt;My experience: AI tools save me 20–40% of development time, depending on the project type. A project that would have taken eight weeks now takes six to seven. With heavy custom UI and little boilerplate, the difference is smaller. With API-heavy backend integrations, it's larger.&lt;/p&gt;

&lt;p&gt;What doesn't change: the concept and design phase. Understanding what to build takes just as long as before. Same for testing and QA. AI accelerates implementation, not the entire product development process.&lt;/p&gt;

&lt;p&gt;For clients, that means concretely: an MVP that previously cost 20,000 € now comes in around 15,000–17,000 €. (More on this in my &lt;a href="https://khal.it/en/blog/was-kostet-eine-app" rel="noopener noreferrer"&gt;post about app costs&lt;/a&gt;.) Not a paradigm shift, but a noticeable improvement. The bigger change is that I can deliver more functionality in the same time — not that the same functionality costs dramatically less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI hits its limits
&lt;/h2&gt;

&lt;p&gt;After two years of daily use, I'm clear on what AI can't do in app development — at least not yet.&lt;/p&gt;

&lt;p&gt;Business context is the biggest gap. AI can write code, but it can't judge whether the feature priority is right or whether the architecture will still scale in six months. Every decision that goes beyond a single function needs human judgment.&lt;/p&gt;

&lt;p&gt;Then there's the missing long-term memory. AI doesn't know the decision from last sprint about why you chose against Redux, or which technical debt you consciously accepted. Context extends beyond the session, and that's missing.&lt;/p&gt;

&lt;p&gt;Hallucinations still happen. Less than a year ago, but they do. Non-existent API methods, outdated syntax, packages that don't exist. If you can't verify the output, you won't notice. That's why AI is a tool for developers, not a replacement.&lt;/p&gt;

&lt;p&gt;And then the quality question. AI produces code that "works." But working code and good code aren't the same thing. Error handling, accessibility, performance under load, edge cases with poor network connectivity — these are the things that matter, and they don't appear in any AI-generated MVP.&lt;/p&gt;

&lt;p&gt;Still: AI is the best tool that's been added to my career as a developer. Not because it replaces me, but because it handles the boring parts and leaves me more time for the interesting ones. If you're considering local AI alternatives to cloud APIs, here's &lt;a href="https://khal.it/blog/lokale-ki-fuer-entwickler" rel="noopener noreferrer"&gt;my experience report&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Planning an app with AI features, or want to know how AI can speed up your project? &lt;a href="https://calendly.com/development-khal/casual-coffee" rel="noopener noreferrer"&gt;Book a free intro call&lt;/a&gt; — I'll give you a realistic assessment of what's possible and what's worth it. More about my approach on the &lt;a href="https://khal.it/en/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;app development page&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mobile</category>
    </item>
    <item>
      <title>AI-Assisted Flutter Development: Claude Code in Production</title>
      <dc:creator>Khalit Hartmann</dc:creator>
      <pubDate>Thu, 02 Jul 2026 15:04:00 +0000</pubDate>
      <link>https://dev.to/khalit_hartmann_17e573503/ai-assisted-flutter-development-claude-code-in-production-1k1g</link>
      <guid>https://dev.to/khalit_hartmann_17e573503/ai-assisted-flutter-development-claude-code-in-production-1k1g</guid>
      <description>&lt;h1&gt;
  
  
  AI-Assisted Flutter Development: Claude Code in Production
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;For senior developers, tech leads, and CTOs wondering whether AI coding tools actually work in production -- or just generate impressive demos.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I used Claude Code across 827 commits in a Flutter e-commerce app for a Swiss retailer. The difference between "vibe coding" and AI-assisted development is not the AI -- it is the infrastructure around it. A &lt;code&gt;CLAUDE.md&lt;/code&gt; file defines project context. Custom skills (slash commands) switch the AI between migration mode, legacy mode, and test mode. Architecture guardrails prevent the AI from "improving" code it should not touch. Result: ~30% faster migration timeline. Not because the AI wrote perfect code, but because it handled the mechanical parts while I made the decisions that required judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vibe Coding vs. AI-Assisted Development
&lt;/h2&gt;

&lt;p&gt;These two terms get used interchangeably in 2026. They should not be.&lt;/p&gt;

&lt;p&gt;Vibe coding is prompting and committing. You describe what you want, the AI generates code, you paste it in. Maybe it works. Maybe it compiles. Maybe it follows the same pattern as the rest of your codebase. You don't know until something breaks, and by then three more features are built on top of it.&lt;/p&gt;

&lt;p&gt;AI-assisted development is structured collaboration. The AI knows your architecture. It knows which patterns to follow in which parts of the codebase. It knows when to use &lt;code&gt;TaskEither&lt;/code&gt; and when to use &lt;code&gt;try/catch&lt;/code&gt;. It knows because you told it -- explicitly, in configuration files that it reads before writing a single line.&lt;/p&gt;

&lt;p&gt;Both use the same underlying models. The difference is entirely in setup. One produces code that looks right in a PR diff. The other produces code that works in production six months later.&lt;/p&gt;

&lt;p&gt;I spent ten months on a project that started as vibe-coded and ended as something far more disciplined.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Project: 827 Commits, One Codebase
&lt;/h2&gt;

&lt;p&gt;The Customer was a renowned Swiss retailer that had just launched their Mobile E-Commerce App 2 Months prior. I joined their Flutter e-commerce app when it had an 85% crash-free rate -- roughly one in seven sessions ended in a crash. API calls inside widgets, three different error handling styles, state management by tutorial roulette.&lt;/p&gt;

&lt;p&gt;Over 22 weeks, I migrated the app to Clean Architecture while shipping features. Crash-free rate past 97%. This post is about the AI tooling that made it possible to move that fast.&lt;/p&gt;

&lt;p&gt;My only AI coding tool was Claude Code -- an agentic coding CLI that reads your entire project and executes multi-step development tasks. Not Copilot, not Cursor. The distinction matters because this workflow depends on Claude Code features: &lt;code&gt;CLAUDE.md&lt;/code&gt; configuration files and custom skills.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Context Problem
&lt;/h2&gt;

&lt;p&gt;Here is what happens when you point an AI at a Flutter codebase without project context.&lt;/p&gt;

&lt;p&gt;You ask it to fetch product data. It writes a service class with &lt;code&gt;try/catch&lt;/code&gt;. You ask it to fetch user data. It writes a repository with &lt;code&gt;TaskEither&lt;/code&gt; from &lt;code&gt;fpdart&lt;/code&gt;. You ask it to handle cart state. It generates a &lt;code&gt;ChangeNotifier&lt;/code&gt;. You already use Riverpod.&lt;/p&gt;

&lt;p&gt;Three requests, three different patterns. Each defensible in isolation. Together, a maintenance nightmare.&lt;/p&gt;

&lt;p&gt;The AI has no way to know your project uses &lt;code&gt;TaskEither&lt;/code&gt; for error handling and &lt;code&gt;AsyncNotifier&lt;/code&gt; for state management. It draws from the sum of all Flutter code it has ever seen, and that sum includes every pattern and anti-pattern in existence.&lt;/p&gt;

&lt;p&gt;The fix is not a smarter model. The fix is project context.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md: Your Project's AI Configuration
&lt;/h2&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file from your project root at the start of every session. Architecture decisions, naming conventions, import rules -- everything the AI needs before it touches your code.&lt;/p&gt;

&lt;p&gt;Here is a simplified version from the retailer project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Architecture&lt;/span&gt;

This project uses Clean Architecture with feature-first organization.

&lt;span class="gu"&gt;### Decision Matrix&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Touching existing unmigrated code? -&amp;gt; Follow legacy patterns
&lt;span class="p"&gt;-&lt;/span&gt; Writing a new feature? -&amp;gt; Use Clean Architecture
&lt;span class="p"&gt;-&lt;/span&gt; Migrating an existing feature? -&amp;gt; Follow migration checklist

&lt;span class="gu"&gt;### Import Rules&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Domain layer: NO imports from data or presentation
&lt;span class="p"&gt;-&lt;/span&gt; Presentation layer: imports domain only
&lt;span class="p"&gt;-&lt;/span&gt; Data layer: implements domain interfaces

&lt;span class="gu"&gt;### Error Handling&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; New code: TaskEither&lt;span class="nt"&gt;&amp;lt;AppFailure&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;T&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; from fpdart
&lt;span class="p"&gt;-&lt;/span&gt; Legacy code: existing try/catch (do NOT refactor)

&lt;span class="gu"&gt;### State Management&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; New features: Riverpod with code generation
&lt;span class="p"&gt;-&lt;/span&gt; AsyncNotifier for async state
&lt;span class="p"&gt;-&lt;/span&gt; Do NOT use ChangeNotifier, StateNotifier, or Bloc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not documentation for humans. Humans have context from standups and PR reviews. &lt;code&gt;CLAUDE.md&lt;/code&gt; is documentation for the AI -- explicit, unambiguous, with decision trees instead of guidelines.&lt;/p&gt;

&lt;p&gt;The decision matrix is the most important part. Without it, the AI defaults to "write the best code possible." During a migration, "best code possible" is context-dependent. A bug fix in unmigrated code should follow legacy patterns. The same logic, written during a migration, should follow Clean Architecture. Same feature, two correct approaches, depending entirely on intent. No model training covers that distinction. It has to be configured per project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Skills: Different Tasks, Different AI Behavior
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;CLAUDE.md&lt;/code&gt; sets the baseline context. But different development tasks need the AI to behave differently. That is where custom skills come in.&lt;/p&gt;

&lt;p&gt;Custom skills are slash commands in Claude Code. Each one loads a specific set of instructions that override or extend the baseline context. On the retailer project, I used five:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/migration&lt;/code&gt; enforces a strict sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;When migrating a feature:
&lt;span class="p"&gt;
1.&lt;/span&gt; Create domain layer first (entities, repository interfaces, use cases)
&lt;span class="p"&gt;2.&lt;/span&gt; Create data layer (implementations, data sources, DTOs)
&lt;span class="p"&gt;3.&lt;/span&gt; Create presentation layer (providers, pages, widgets)
&lt;span class="p"&gt;4.&lt;/span&gt; Write tests for each layer
&lt;span class="p"&gt;5.&lt;/span&gt; Remove legacy code only after tests pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI does not jump ahead to the presentation layer because it is "easier." It starts with the domain, writes the interfaces, and builds outward. Every migrated feature has the same structure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/legacy-code&lt;/code&gt; is the opposite. "Follow existing patterns. Do not introduce Clean Architecture imports. Match the style of surrounding code." This was the hardest skill to get right. The AI's instinct is to improve. It sees a &lt;code&gt;try/catch&lt;/code&gt; and wants to refactor it into &lt;code&gt;TaskEither&lt;/code&gt;. That instinct is correct in migration mode and catastrophic in legacy mode. A "quick improvement" to a legacy service class can break five widgets that depend on its exact interface.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ui-component&lt;/code&gt; loads design system rules. &lt;code&gt;/riverpod&lt;/code&gt; enforces code generation with &lt;code&gt;@riverpod&lt;/code&gt; annotation and &lt;code&gt;AsyncNotifier&lt;/code&gt;. &lt;code&gt;/test-workflow&lt;/code&gt; sets testing conventions -- unit tests for use cases, widget tests for composed components, mock repositories via domain interfaces.&lt;/p&gt;

&lt;p&gt;Each skill changes the AI's behavior without changing the AI itself. The model is the same. The context is different. (For more on how AI agents and tool systems like these work, see &lt;a href="https://khal.it/blog/ki-agenten-mcp-tools-erklaert" rel="noopener noreferrer"&gt;AI Agents, MCP, and Tools Explained&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Guardrails: The Biggest Win
&lt;/h2&gt;

&lt;p&gt;If I had to pick one concept from this entire setup that delivered the most value, it is architecture guardrails.&lt;/p&gt;

&lt;p&gt;During a migration, two valid architectural styles coexist for months. Every task requires a decision -- which style applies here? Humans handle this through judgment. AI does not have that judgment. Without explicit guardrails, it sees legacy code and "improves" it. That creates a third style -- half-migrated code that follows neither convention consistently. Worse than legacy code because it is unpredictable.&lt;/p&gt;

&lt;p&gt;The decision matrix in &lt;code&gt;CLAUDE.md&lt;/code&gt; solved this. Not a suggestion -- a rule. "Touching unmigrated code? Follow legacy patterns." No ambiguity.&lt;/p&gt;

&lt;p&gt;Here is what the same task looks like with and without guardrails.&lt;/p&gt;

&lt;p&gt;Without guardrails -- fixing a bug in a legacy service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI "improves" the legacy code while fixing the bug&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;TaskEither&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppFailure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TaskEither&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryCatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/products/&lt;/span&gt;&lt;span class="si"&gt;$id&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AppFailure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unexpected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Problem: 12 widgets depend on Future&amp;lt;Product&amp;gt;, not TaskEither&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With guardrails -- same bug, same service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI fixes the bug using existing patterns&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/products/&lt;/span&gt;&lt;span class="si"&gt;$id&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ProductNotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;rethrow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Bug fixed, existing interface preserved&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version is not "better code" in the abstract. It is the correct code for this context -- context-dependent correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Is Good At (and Where It Falls Short)
&lt;/h2&gt;

&lt;p&gt;After 827 commits using Claude Code, I have a clear picture of where AI pair programming delivers value and where it does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it excels
&lt;/h3&gt;

&lt;p&gt;Boilerplate is the obvious one. Clean Architecture is verbose by design -- entity classes, repository interfaces, use cases, data sources, DTOs, mappers, provider declarations. The structure is formulaic. Once the AI has seen two migrated features, it generates the scaffolding for the third with minimal correction.&lt;/p&gt;

&lt;p&gt;Test generation is close behind. "Write unit tests for this use case" with the project's testing conventions loaded produces usable tests 80% of the time. The remaining 20% need manual adjustment, usually around edge cases the AI cannot infer from the interface alone.&lt;/p&gt;

&lt;p&gt;Pattern consistency is something AI handles better than humans. A developer on their fifteenth provider declaration in a week starts taking shortcuts. The AI does not get tired. Every &lt;code&gt;AsyncNotifier&lt;/code&gt; follows the same structure. That consistency compounds over months.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it falls short
&lt;/h3&gt;

&lt;p&gt;Architectural decisions remain firmly human territory. "Should we introduce a caching layer here?" depends on traffic patterns, backend SLA, and six other factors the AI cannot observe. It will give you a confident answer. That answer may be wrong.&lt;/p&gt;

&lt;p&gt;Business context is invisible to the AI. It does not know that the German market has different legal requirements for price display than the Swiss market, or that certain API fields return stale data because the backend redesign is not finished. These are the things that cause real bugs.&lt;/p&gt;

&lt;p&gt;Subtle bugs are the dangerous category. The AI writes code that compiles, passes the tests it generated, and looks correct in review. But it might use the wrong comparison operator for a currency calculation or handle a timezone edge case incorrectly. AI-generated code needs the same scrutiny as human-written code. Arguably more, because its confidence makes it easier to rubber-stamp.&lt;/p&gt;

&lt;p&gt;The AI also lacks the judgment to not write code. Sometimes the correct response is "this duplicates existing functionality." The AI will always produce something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Impact: ~30% Faster, Not Magic
&lt;/h2&gt;

&lt;p&gt;The migration took 22 weeks. Without AI-assisted development, my estimate is 30-32 weeks. That is roughly six to eight weeks saved -- not the ten-times productivity claim you see in conference talks.&lt;/p&gt;

&lt;p&gt;The 30% comes from two sources. The mechanical parts of each migration step were faster -- scaffolding, boilerplate, initial test suites. And pattern consistency reduced the review cycle. Fewer "why does this feature handle errors differently?" conversations.&lt;/p&gt;

&lt;p&gt;What AI did not speed up: architectural planning, debugging production issues via Crashlytics traces, and the final QA pass on each step.&lt;/p&gt;

&lt;p&gt;The honest math: 70% of the speed gain came from boilerplate reduction. 30% came from consistency enforcement. Zero percent came from the AI making better architectural choices than I would have. I applied a similar Claude Code workflow in a &lt;a href="https://khal.it/blog/migrating-serverless-to-monolith-with-claude-code" rel="noopener noreferrer"&gt;serverless-to-monolith migration&lt;/a&gt; with comparable results.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If your team is planning a similar migration or evaluating how AI tooling fits into production workflows, &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;I have done this before&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting This Up for Your Project
&lt;/h2&gt;

&lt;p&gt;This works for any project, not just Flutter -- I use the same CLAUDE.md and custom skills approach on web apps, backend services, and infrastructure projects. For a broader perspective on AI in app development, see &lt;a href="https://khal.it/blog/app-entwickeln-mit-ki" rel="noopener noreferrer"&gt;Building Apps with AI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Start with &lt;code&gt;CLAUDE.md&lt;/code&gt;. Document your architecture in machine-readable terms. Not "we prefer clean code" -- the AI does not know what you mean by that. Instead: "Domain layer classes live in &lt;code&gt;lib/features/{name}/domain/&lt;/code&gt;. They must not import from &lt;code&gt;data/&lt;/code&gt; or &lt;code&gt;presentation/&lt;/code&gt;. Error handling uses &lt;code&gt;TaskEither&amp;lt;AppFailure, T&amp;gt;&lt;/code&gt;." Concrete paths. Concrete types. Concrete rules.&lt;/p&gt;

&lt;p&gt;Add a decision matrix. If your codebase has multiple styles (most do), codify which style applies when. Write it as a decision tree, not prose.&lt;/p&gt;

&lt;p&gt;Create your first custom skill for whatever task you do most often. Start with one. Add more as you identify repeated patterns in your AI interactions.&lt;/p&gt;

&lt;p&gt;Run the AI on low-risk tasks first. A utility function. A test for an existing module. Review the output carefully -- import paths, naming conventions, whether it followed the &lt;code&gt;CLAUDE.md&lt;/code&gt; rules. Update &lt;code&gt;CLAUDE.md&lt;/code&gt; when the instructions are ambiguous. Expand to feature work once the context is dialed in.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;CLAUDE.md&lt;/code&gt; is a living document. Mine changed over fifty times during the retailer project. Over ten months, it became a remarkably precise description of the project's architecture. A side effect: it is also the best onboarding document the project has.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Teams
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;CLAUDE.md&lt;/code&gt; and custom skills are project-level configuration, not personal preference. When the entire team uses them, a junior developer using &lt;code&gt;/migration&lt;/code&gt; gets the same structural scaffolding as a senior developer. The architecture decisions are encoded in the tooling, not locked inside one person's head.&lt;/p&gt;

&lt;p&gt;This shifts code review from "did you follow the pattern?" to "is the business logic correct?" And it forces the team to articulate rules that previously lived as tribal knowledge. "When you fix a bug in legacy code, follow legacy patterns" is a nuanced rule most teams never write down. The AI forces you to write it down, and the whole team benefits.&lt;/p&gt;

&lt;p&gt;There is a handover benefit too: the &lt;code&gt;CLAUDE.md&lt;/code&gt; and custom skills stay with the codebase. When a freelancer finishes an engagement, the team inherits a precise, machine-readable description of their own architecture. No knowledge walks out the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agentic Coding: Where This Is Heading
&lt;/h2&gt;

&lt;p&gt;The current setup -- AI as a careful assistant with explicit guardrails -- is an intermediate step. The next stage is agentic coding: the AI executes multi-step tasks autonomously, creates files, runs tests, fixes failures, and commits the result.&lt;/p&gt;

&lt;p&gt;For me, this is already partially reality in 2026. Claude Code can run a migration end-to-end -- create the file structure, generate the code, write tests, run them, and correct failures. I review the outcome instead of every intermediate step.&lt;/p&gt;

&lt;p&gt;But the same principle holds: autonomy without guardrails is dangerous. The more autonomy the AI gets, the more important the architecture rules in &lt;code&gt;CLAUDE.md&lt;/code&gt; become. Without them, autonomous coding is just vibe coding with extra steps.&lt;/p&gt;

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

&lt;p&gt;AI-assisted development in production is not about the AI being smart. It is about the developer being deliberate.&lt;/p&gt;

&lt;p&gt;In a production codebase, AI without project context generates code that looks right and behaves wrong. Without guardrails, it "improves" code that should not be touched. The infrastructure matters more than the model -- &lt;code&gt;CLAUDE.md&lt;/code&gt;, custom skills, explicit rules about which patterns apply where. That is what separates vibe coding from production-grade AI development. When this setup becomes your default rather than an experiment, AI is no longer assisting your development -- it is native to it.&lt;/p&gt;

&lt;p&gt;My results on the retailer project: 827 commits, crash-free rate from 85% to past 97%, ~30% faster migration timeline. Not because the AI was magic. Because the AI had context.&lt;/p&gt;

&lt;p&gt;The bar for AI-assisted development in 2026 is not "can the AI write code?" It can. The bar is: "can the AI write code that belongs in your codebase?" That takes work. The work is worth it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I help teams set up AI-assisted development workflows for production codebases. If that is relevant for your project, &lt;a href="https://khal.it/leistungen/app-entwicklung" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between vibe coding and AI-assisted development?
&lt;/h3&gt;

&lt;p&gt;Vibe coding means prompting an AI and committing the output with minimal review or structural guidance. AI-assisted development means providing the AI with explicit project context -- architecture rules, conventions, decision matrices -- so that its output is consistent with the existing codebase. Same models, different infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is CLAUDE.md?
&lt;/h3&gt;

&lt;p&gt;A configuration file that Claude Code reads from your project root at the start of every session. It contains architecture descriptions, naming conventions, import rules, and decision trees that guide the AI's behavior -- project documentation written for the AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can AI coding assistants handle architecture migrations?
&lt;/h3&gt;

&lt;p&gt;With guardrails, yes. Unguided, an AI will try to "improve" all code it touches, creating pattern chaos during a migration. Custom skills and decision matrices in &lt;code&gt;CLAUDE.md&lt;/code&gt; constrain the AI to follow the correct patterns for each context.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much faster is AI-assisted Flutter development?
&lt;/h3&gt;

&lt;p&gt;Roughly 20-30% faster for implementation work. Boilerplate-heavy work sees the biggest gains. Architectural planning, debugging, and QA are not meaningfully faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this approach work with tools other than Claude Code?
&lt;/h3&gt;

&lt;p&gt;The concepts -- project context files, task-specific configurations, architecture guardrails -- apply broadly. The specific implementation (&lt;code&gt;CLAUDE.md&lt;/code&gt;, custom skills) is Claude Code. Other tools have their own mechanisms. The principle is the same: give the AI explicit, machine-readable project context.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>flutter</category>
      <category>devtools</category>
      <category>claudecode</category>
    </item>
  </channel>
</rss>
