<?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: Muhammad Uzair</title>
    <description>The latest articles on DEV Community by Muhammad Uzair (@uzairxdev).</description>
    <link>https://dev.to/uzairxdev</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%2F4163455%2F0300d474-bb67-4077-8a05-f7936953db8e.jpg</url>
      <title>DEV Community: Muhammad Uzair</title>
      <link>https://dev.to/uzairxdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/uzairxdev"/>
    <language>en</language>
    <item>
      <title>SDUI 2.0: Generative AI and Dynamic Mobile Layouts</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Mon, 26 Oct 2026 03:01:23 +0000</pubDate>
      <link>https://dev.to/uzairxdev/sdui-20-generative-ai-and-dynamic-mobile-layouts-3d8k</link>
      <guid>https://dev.to/uzairxdev/sdui-20-generative-ai-and-dynamic-mobile-layouts-3d8k</guid>
      <description>&lt;h1&gt;
  
  
  SDUI 2.0: Generative AI and Dynamic Mobile Layouts
&lt;/h1&gt;

&lt;p&gt;Server-Driven UI (SDUI) is not a new concept. Companies like Airbnb and Spotify have used it for years to push layout updates without waiting for App Store approvals. But in late 2026, we are witnessing the birth of &lt;strong&gt;SDUI 2.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The difference? The backend JSON isn't static anymore. It is being generated in real-time by Artificial Intelligence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shift to AI-Generated Layouts
&lt;/h2&gt;

&lt;p&gt;In SDUI 1.0, a product manager would define a layout configuration in a CMS, and the server would send that exact JSON to the client.&lt;/p&gt;

&lt;p&gt;In SDUI 2.0, the backend utilizes generative AI models that ingest real-time analytics, user behavior, and context (like time of day or location) to construct a &lt;strong&gt;hyper-personalized UI schema&lt;/strong&gt; on the fly. &lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works in Practice
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Rendering Engine:&lt;/strong&gt; The mobile app (often built in Flutter or Compose) acts purely as a dumb rendering engine. It knows how to draw a "Card", a "Button", or a "Carousel", but it has no idea what the screen is supposed to look like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI Brain:&lt;/strong&gt; When the user opens the app, the backend AI evaluates their profile. If they are a power user, the AI generates a dense, data-rich UI schema. If they are a new user, the AI generates a simplified, tutorial-focused UI schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Execution:&lt;/strong&gt; The mobile app receives the JSON payload, parses it, and dynamically renders the customized layout in milliseconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;This architecture allows apps to literally A/B test themselves. The AI can continuously tweak button placements, colors, and content density to maximize engagement for every individual user. As a mobile engineer, your job is no longer building screens—it is building the robust rendering engines capable of interpreting whatever the AI dreams up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/genai-powered-sdui-2-0" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
      <category>mobile</category>
      <category>ai</category>
    </item>
    <item>
      <title>Dart Streams vs Futures: When to Use Each</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:59:42 +0000</pubDate>
      <link>https://dev.to/uzairxdev/dart-streams-vs-futures-when-to-use-each-3j32</link>
      <guid>https://dev.to/uzairxdev/dart-streams-vs-futures-when-to-use-each-3j32</guid>
      <description>&lt;p&gt;Every Flutter developer meets Futures on day one: you call an API, you await the result, you show it on screen. Then, sooner or later, you meet the second half of Dart async programming — Streams — and the confusion starts. Both deal with values that arrive later. Both use await in some form. So when do you reach for a Future, and when do you need a Stream?&lt;/p&gt;

&lt;p&gt;The answer is simpler than most tutorials make it sound. A Future delivers one value, once, at some point in the future. A Stream delivers zero or more values, over time, until it closes. That single distinction drives every decision in this guide: which type to return from a function, how to consume it in a widget, and which mistakes to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Futures: one value, later
&lt;/h2&gt;

&lt;p&gt;A Future represents a computation that has not finished yet. It will complete with either a value or an error, exactly once. Network requests, file reads, database queries, and shared-preferences loads are all naturally Futures, because each one produces a single answer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
Future&amp;lt;User&amp;gt; fetchUser(String id) async {&lt;br&gt;
  final response = await http.get(Uri.parse('https://api.example.com/users/' + id));&lt;br&gt;
  if (response.statusCode != 200) {&lt;br&gt;
    throw Exception('Failed to load user');&lt;br&gt;
  }&lt;br&gt;
  return User.fromJson(jsonDecode(response.body));&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Consuming a Future is straightforward. Await it inside an async function, or use a FutureBuilder in a widget. Always handle the error path: an unhandled Future error becomes a red screen in debug builds and a silent failure in release builds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
Future&amp;lt;void&amp;gt; loadUser() async {&lt;br&gt;
  try {&lt;br&gt;
    final user = await fetchUser('42');&lt;br&gt;
    setState(() =&amp;gt; _user = user);&lt;br&gt;
  } catch (e) {&lt;br&gt;
    setState(() =&amp;gt; _error = 'Could not load the profile. Try again.');&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two Future utilities cover most real-world needs. Future.wait runs several independent Futures in parallel and returns all their results at once, which is far faster than awaiting them one after another. The timeout method caps how long you are willing to wait, so a hung request cannot freeze your UI forever.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
Future&amp;lt;DashboardData&amp;gt; loadDashboard() async {&lt;br&gt;
  final results = await Future.wait([&lt;br&gt;
    fetchUser('42'),&lt;br&gt;
    fetchNotifications(),&lt;br&gt;
    fetchSettings(),&lt;br&gt;
  ]).timeout(const Duration(seconds: 10));&lt;br&gt;
  return DashboardData(&lt;br&gt;
    user: results[0] as User,&lt;br&gt;
    notifications: results[1] as List&amp;lt;NotificationItem&amp;gt;,&lt;br&gt;
    settings: results[2] as Settings,&lt;br&gt;
  );&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If the work produces exactly one result, it is a Future. Full stop. Wrapping a single API call in a Stream adds complexity for no benefit, and it is one of the most common over-engineering mistakes in Flutter codebases. The REST integration patterns in &lt;a href="https://www.uzairxdev.online/blog/flutter-rest-api-integration-from-json-to-ui" rel="noopener noreferrer"&gt;Flutter REST API Integration: From JSON to UI&lt;/a&gt; all follow this rule: one request, one Future, one result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streams: many values, over time
&lt;/h2&gt;

&lt;p&gt;A Stream is a sequence of asynchronous events. It can emit many values, it can emit errors, and eventually it closes with a done event. User input, sensor data, WebSocket messages, Firebase realtime updates, and download progress are all naturally Streams, because the data keeps arriving after the first value.&lt;/p&gt;

&lt;p&gt;The simplest way to create one is a StreamController: you add events to its sink, and listeners receive them through its stream.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class DownloadTracker {&lt;br&gt;
  final _controller = StreamController();&lt;/p&gt;

&lt;p&gt;Stream get progress =&amp;gt; _controller.stream;&lt;/p&gt;

&lt;p&gt;Future download(String url) async {&lt;br&gt;
    final request = http.Request('GET', Uri.parse(url));&lt;br&gt;
    final response = await http.Client().send(request);&lt;br&gt;
    final total = response.contentLength ?? 0;&lt;br&gt;
    int received = 0;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await for (final chunk in response.stream) {
  received += chunk.length;
  if (total &amp;gt; 0) {
    _controller.add(received / total);
  }
}
await _controller.close();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note two details. First, the controller is closed when the work finishes, so listeners know no more events are coming. Second, the download itself still uses a Future internally: the HTTP request completes once, while the progress Stream reports many values along the way. Futures and Streams compose like this all the time; they are not rivals.&lt;/p&gt;

&lt;p&gt;In a widget, StreamBuilder is the counterpart of FutureBuilder. It rebuilds whenever a new event arrives, and its snapshot tells you the connection state, the latest data, and any error.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
StreamBuilder&amp;lt;double&amp;gt;(&lt;br&gt;
  stream: tracker.progress,&lt;br&gt;
  builder: (context, snapshot) {&lt;br&gt;
    if (snapshot.hasError) {&lt;br&gt;
      return const Text('Download failed');&lt;br&gt;
    }&lt;br&gt;
    final value = snapshot.data ?? 0.0;&lt;br&gt;
    return Column(&lt;br&gt;
      children: [&lt;br&gt;
        LinearProgressIndicator(value: value),&lt;br&gt;
        Text('Downloaded ' + (value * 100).round().toString() + '%'),&lt;br&gt;
      ],&lt;br&gt;
    );&lt;br&gt;
  },&lt;br&gt;
)&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Always handle snapshot.hasError and the waiting state explicitly. A StreamBuilder that only reads snapshot.data will show a blank or stale widget while the stream is still connecting, and users will read that as a broken screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating streams with async star and yield
&lt;/h2&gt;

&lt;p&gt;For streams you generate yourself, async star functions are cleaner than a hand-managed controller. Mark the function async star, yield each value as it becomes available, and Dart builds the Stream for you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
Stream&amp;lt;int&amp;gt; countdown(int from) async* {&lt;br&gt;
  for (var i = from; i &amp;gt;= 0; i--) {&lt;br&gt;
    yield i;&lt;br&gt;
    await Future.delayed(const Duration(seconds: 1));&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Each yield pauses the function until the listener is ready for the next value, so a slow listener naturally applies backpressure instead of being flooded. You can also transform streams without writing loops: map converts each event, where filters events by a condition, and distinct drops consecutive duplicates.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
final evenCountdown = countdown(10)&lt;br&gt;
    .where((value) =&amp;gt; value % 2 == 0)&lt;br&gt;
    .map((value) =&amp;gt; 'T-minus ' + value.toString());&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Stream transformers are lazy: nothing runs until something listens. That is a feature, not a bug — it means creating a stream costs almost nothing, and you only pay for the work when a listener actually needs the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single-subscription versus broadcast streams
&lt;/h2&gt;

&lt;p&gt;By default, a Dart stream allows exactly one listener. Listen a second time and you get a StateError. That default is correct for most cases: a file download, a sensor feed, or a WebSocket connection should have one owner that controls its lifecycle.&lt;/p&gt;

&lt;p&gt;When several widgets genuinely need the same events — for example, a connectivity indicator in the app bar and a banner on the home screen — convert the stream with asBroadcastStream. Broadcast streams allow many listeners, but they come with two trade-offs: late listeners miss events that already happened, and the stream keeps running even when nobody is listening unless you manage it yourself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
final connectivity = Connectivity()&lt;br&gt;
    .onConnectivityChanged&lt;br&gt;
    .asBroadcastStream();&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A practical rule: start single-subscription, and switch to broadcast only when a second listener actually appears. Premature broadcast streams are a classic source of missed-event bugs, because developers assume every listener sees the full history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Load a user profile from an API&lt;/td&gt;
&lt;td&gt;Future&lt;/td&gt;
&lt;td&gt;One result, then done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watch auth state changes&lt;/td&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;State can change many times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a file from disk&lt;/td&gt;
&lt;td&gt;Future&lt;/td&gt;
&lt;td&gt;One result, then done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receive WebSocket messages&lt;/td&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;Messages arrive indefinitely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run three API calls in parallel&lt;/td&gt;
&lt;td&gt;Future.wait&lt;/td&gt;
&lt;td&gt;All results needed together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show download progress&lt;/td&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;Progress updates over time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debounce search input&lt;/td&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;Keystrokes arrive continuously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache a value for later reuse&lt;/td&gt;
&lt;td&gt;Future&lt;/td&gt;
&lt;td&gt;The value is computed once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When in doubt, count the values. One value means Future. Many values over time means Stream. If you find yourself adding a Stream just to deliver one value, simplify it to a Future. If you find yourself polling a Future in a timer loop to get fresh values, that polling loop is a Stream waiting to be written.&lt;/p&gt;

&lt;p&gt;This distinction also explains why state management libraries lean on streams so heavily. BLoC, for example, models state as a stream of states over time, which is why understanding streams makes BLoC click. The tutorial in &lt;a href="https://www.uzairxdev.online/blog/flutter-bloc-pattern-tutorial" rel="noopener noreferrer"&gt;Flutter BLoC Pattern Tutorial: Zero to Production&lt;/a&gt; builds directly on the concepts above: events go in, states come out, and the UI rebuilds on each new state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five mistakes every Flutter developer makes once
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Awaiting a stream.&lt;/strong&gt; Awaiting a Stream gives you its first event only if you use the first getter, or hangs until it closes if you use await for incorrectly in the wrong place. If you need every event, listen or use await for inside an async function — never await the stream object itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listening twice to a single-subscription stream.&lt;/strong&gt; Two widgets subscribing to the same controller stream throws at runtime. Share the data through a state management solution or convert to broadcast deliberately, as discussed above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting to cancel a subscription.&lt;/strong&gt; A StreamSubscription keeps its source alive. Cancel it in dispose, or use StreamBuilder and let the framework manage the subscription for you. Leaked subscriptions are a quiet memory leak: the stream keeps emitting, the disposed widget keeps receiving, and the app slowly degrades.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class _TickerState extends State {&lt;br&gt;
  late final StreamSubscription _subscription;&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/override"&gt;@override&lt;/a&gt;&lt;br&gt;
  void initState() {&lt;br&gt;
    super.initState();&lt;br&gt;
    _subscription = countdown(60).listen((value) {&lt;br&gt;
      if (mounted) setState(() =&amp;gt; _secondsLeft = value);&lt;br&gt;
    });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/override"&gt;@override&lt;/a&gt;&lt;br&gt;
  void dispose() {&lt;br&gt;
    _subscription.cancel();&lt;br&gt;
    super.dispose();&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never closing a StreamController.&lt;/strong&gt; An unclosed controller holds its listeners forever and confuses code that waits for the done event. Close it in dispose, or prefer async star functions, which close automatically when the function returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring errors in streams.&lt;/strong&gt; Stream errors do not throw into surrounding try-catch blocks the way Future errors do. Handle them with onError in listen, check snapshot.hasError in StreamBuilder, or use handleError in a transformation chain. An unhandled stream error crashes the zone, which in a Flutter app usually means a red screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete example: live search
&lt;/h2&gt;

&lt;p&gt;Live search ties everything together. Keystrokes arrive as a stream, you debounce them so you do not fire a request per character, and each debounced query becomes a single Future that fetches results.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class SearchService {&lt;br&gt;
  final _queryController = StreamController();&lt;/p&gt;

&lt;p&gt;Stream&amp;gt; get results =&amp;gt; _queryController.stream&lt;br&gt;
      .distinct()&lt;br&gt;
      .asyncMap((query) =&amp;gt; searchApi(query));&lt;/p&gt;

&lt;p&gt;void onQueryChanged(String query) {&lt;br&gt;
    _queryController.add(query);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;Future&amp;gt; searchApi(String query) async {&lt;br&gt;
    await Future.delayed(const Duration(milliseconds: 300));&lt;br&gt;
    return ['Result for ' + query];&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;void dispose() {&lt;br&gt;
    _queryController.close();&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The pattern is worth studying: a Stream for the continuous input, a Future for each discrete lookup, distinct to skip duplicate queries, and asyncMap to turn each query into its async result. Most real-time Flutter features are variations of this shape. Keeping the network layer behind a clean boundary, as in &lt;a href="https://www.uzairxdev.online/blog/flutter-repository-pattern-stop-putting-api-calls-in-widgets" rel="noopener noreferrer"&gt;Flutter Repository Pattern: Keep API Calls Out of Widgets&lt;/a&gt;, makes this pattern easy to test: feed queries into the stream, await the results, and assert.&lt;/p&gt;

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

&lt;p&gt;Futures and Streams are not two ways of doing the same thing. A Future is a promise of one value. A Stream is a sequence of values over time. Choose by counting the values, handle errors on the path each type actually uses, cancel subscriptions, close controllers, and let StreamBuilder or FutureBuilder own the widget lifecycle whenever you can. Get that mental model right, and the rest of Dart async programming — isolates, async generators, state management — falls into place, because all of it is built on these two ideas. The official Dart documentation on asynchronous programming at &lt;a href="https://dart.dev/language/async" rel="noopener noreferrer"&gt;dart.dev&lt;/a&gt; is the best next read once these patterns feel natural.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/dart-streams-vs-futures-flutter" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>streams</category>
      <category>futures</category>
    </item>
    <item>
      <title>Server-Driven UI (SDUI) in Flutter: Scaling Enterprise Apps</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Sun, 25 Oct 2026 03:00:23 +0000</pubDate>
      <link>https://dev.to/uzairxdev/server-driven-ui-sdui-in-flutter-scaling-enterprise-apps-538c</link>
      <guid>https://dev.to/uzairxdev/server-driven-ui-sdui-in-flutter-scaling-enterprise-apps-538c</guid>
      <description>&lt;h1&gt;
  
  
  Server-Driven UI (SDUI) in Flutter: Scaling Enterprise Apps
&lt;/h1&gt;

&lt;p&gt;If you work on a massive, consumer-facing mobile app (like e-commerce, banking, or streaming), you know the pain of the App Store release cycle. &lt;/p&gt;

&lt;p&gt;Marketing wants to change the layout of the home page for a weekend sale. In a traditional app, this means writing code, pushing a build, waiting for Apple/Google approval, and hoping users actually update the app. By the time it's live, the weekend is over.&lt;/p&gt;

&lt;p&gt;The solution that has taken over the enterprise Flutter world in 2026 is &lt;strong&gt;Server-Driven UI (SDUI)&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SDUI?
&lt;/h2&gt;

&lt;p&gt;In a Server-Driven UI architecture, the client app does not hardcode the layout of a screen. Instead, the backend API returns a JSON payload that describes the UI, and the Flutter app acts as a rendering engine that translates that JSON into Widgets dynamically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example JSON Payload:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Column"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HeroBanner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"imageUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/sale.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"actionUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/checkout"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ProductGrid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Flutter Excels at SDUI
&lt;/h2&gt;

&lt;p&gt;Flutter is uniquely positioned for SDUI because of its declarative widget tree. Libraries like &lt;code&gt;json_dynamic_widget&lt;/code&gt; or custom parsing engines allow developers to seamlessly map JSON nodes to Flutter Widgets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benefits:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant Updates:&lt;/strong&gt; Change the UI layout, swap components, or launch promotions instantly across all devices without a new app release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B Testing:&lt;/strong&gt; You can serve different UI payloads to different user segments from the backend to see which layout converts better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-Platform Consistency:&lt;/strong&gt; Since the backend controls the layout, iOS, Android, and Web clients are guaranteed to look and behave exactly the same.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Catch
&lt;/h2&gt;

&lt;p&gt;SDUI is complex to set up. It requires a highly coordinated effort between backend and frontend teams. It is not suitable for complex, highly interactive, or animated screens. It is best used for dynamic content feeds, storefronts, and onboarding flows.&lt;/p&gt;

&lt;p&gt;If you are architecting a large-scale Flutter app in 2026, implementing an SDUI engine for your primary marketing screens is one of the highest ROI investments you can make.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/server-driven-ui-flutter-2026" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Flutter Secure QR Medical Profile Sharing</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Sun, 25 Oct 2026 02:59:19 +0000</pubDate>
      <link>https://dev.to/uzairxdev/flutter-secure-qr-medical-profile-sharing-ok3</link>
      <guid>https://dev.to/uzairxdev/flutter-secure-qr-medical-profile-sharing-ok3</guid>
      <description>&lt;p&gt;In an emergency, first responders need facts in seconds: allergies, blood group, chronic conditions, emergency contacts. The problem is that the fastest way to share those facts — a QR code anyone can scan — is also the least private way to store them. That tension shaped &lt;a href="https://www.uzairxdev.online/projects/medtagid" rel="noopener noreferrer"&gt;MedTagID&lt;/a&gt;, an app that stores emergency medical details and shares them instantly through a secure QR code, with encrypted storage, a simple dashboard for updating the profile, and Firebase Auth plus Firestore behind it.&lt;/p&gt;

&lt;p&gt;This guide covers the Flutter pattern that makes that design work: a QR code that encodes a reference instead of a record, encryption before anything touches the cloud, and Firestore rules that keep the profile owned by exactly one person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: the QR code is a pointer, not a record
&lt;/h2&gt;

&lt;p&gt;A QR code is not a secure container. Any phone camera can decode it, any screenshot preserves it, and a printed code cannot be recalled once it leaves your hands. So the code must never contain allergies, medications, or conditions. It contains one thing: a short, opaque profile reference such as a document ID.&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;EmergencyProfile&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;EmergencyProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bloodGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allergies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;medications&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emergencyContacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;displayName&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;bloodGroup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;allergies&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;medications&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;emergencyContacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// Only the fields the user explicitly marked as shareable.&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;toEmergencyView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;'displayName'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;'bloodGroup'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bloodGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;'allergies'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;allergies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;'conditions'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;'emergencyContacts'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;emergencyContacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is missing from &lt;code&gt;toEmergencyView&lt;/code&gt;: the full medication list and anything else the user did not choose to share. The QR code points at the profile; the app decides what a responder sees. If the user later changes their mind about a field, the change applies everywhere at once because the code never carried the data in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypt on the device, before Firestore
&lt;/h2&gt;

&lt;p&gt;Encryption that happens on the server protects data in transit. Encryption that happens on the device protects it in storage too: a leaked export, a misconfigured bucket, or an over-broad rule cannot hand over readable medical details. The pattern is simple — encrypt sensitive fields with AES before the write, and decrypt only when rendering.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProfileCrypto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ProfileCrypto&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;base64Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base64Key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;_iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IV&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Key&lt;/span&gt; &lt;span class="n"&gt;_key&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;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IV&lt;/span&gt; &lt;span class="n"&gt;_iv&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;encryptText&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;plain&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;encrypter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Encrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;encrypter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;iv:&lt;/span&gt; &lt;span class="n"&gt;_iv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;base64&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="n"&gt;decryptText&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;cipher&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;encrypter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Encrypter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;encrypter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decrypt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;iv:&lt;/span&gt; &lt;span class="n"&gt;_iv&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;Two rules matter more than the cipher itself. First, the key must live in secure storage on the device — Flutter Secure Storage backed by the platform keystore — never in Firestore, never in source control, and never inside the QR code. Second, encrypt field by field rather than the whole document, so the app can still query by owner ID and updated timestamp without decrypting everything. The document ID and ownership fields stay plaintext; the medical content does not.&lt;/p&gt;

&lt;p&gt;A related lesson from the health side of this work: a vitals app only works if logging takes seconds, which is why the entry flow in &lt;a href="https://www.uzairxdev.online/blog/flutter-health-vitals-tracker" rel="noopener noreferrer"&gt;Build a Health Vitals Tracker in Flutter&lt;/a&gt; is built around one-handed, sub-ten-second input. The same discipline applies here — the dashboard for editing a medical profile must be fast enough that people actually keep it up to date, because a stale emergency profile is worse than no profile at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate the QR code with qr_flutter
&lt;/h2&gt;

&lt;p&gt;With the reference in hand, rendering the code is the easy part. The &lt;code&gt;qr_flutter&lt;/code&gt; package draws a &lt;code&gt;QrImageView&lt;/code&gt; from any string, and the string here is just the profile reference.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmergencyQrCard&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatelessWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;EmergencyQrCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;profileId&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;profileId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The QR payload is a reference only. No medical data is encoded.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'medtag://profile/'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;profileId&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;Card&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;Padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;padding:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;EdgeInsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&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;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;mainAxisSize:&lt;/span&gt; &lt;span class="n"&gt;MainAxisSize&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;children:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;QrImageView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="nl"&gt;data:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nl"&gt;version:&lt;/span&gt; &lt;span class="n"&gt;QrVersions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nl"&gt;size:&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nl"&gt;errorCorrectionLevel:&lt;/span&gt; &lt;span class="n"&gt;QrErrorCorrectLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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="mi"&gt;12&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;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="s"&gt;'Show this code to a first responder.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nl"&gt;textAlign:&lt;/span&gt; &lt;span class="n"&gt;TextAlign&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;center&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;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;Error correction level M is a deliberate choice: it keeps the code dense enough to scan from a phone screen while tolerating the smudges and glare that printed cards collect. Regenerate this widget whenever the profile changes — the payload stays the same reference, but re-rendering on every profile save guarantees the displayed code and the stored profile never drift apart. If a printed card is lost, the user rotates the reference: generate a new profile ID, re-point the record, and the old card becomes useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock Firestore down to the owner
&lt;/h2&gt;

&lt;p&gt;Encryption is the second line of defence; security rules are the first. Only the authenticated owner writes the profile, and only the owner reads the full document. The responder view is a separate, narrow document that contains only the emergency subset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /profiles/{profileId} {
      allow read, write: if request.auth != null
        &amp;amp;&amp;amp; request.auth.uid == resource.data.ownerId;
    }
    match /emergencyViews/{profileId} {
      // Narrow, user-approved subset only. No full record here.
      allow read: if true;
      allow write: if request.auth != null
        &amp;amp;&amp;amp; request.auth.uid == resource.data.ownerId;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This split is the whole architecture in one place. The full encrypted profile is owner-only. The emergency view is readable by design — that is its job — but it contains only what the user approved, and nothing in it can be used to reach the full record. If you are setting up authentication for the first time, the email, Google, and phone flows in &lt;a href="https://www.uzairxdev.online/blog/flutter-firebase-authentication-email-google-phone" rel="noopener noreferrer"&gt;Flutter Firebase Authentication: Email, Google and Phone&lt;/a&gt; cover the sign-in side of this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard is a safety feature, not a settings page
&lt;/h2&gt;

&lt;p&gt;MedTagID treats the profile dashboard as part of the safety story: an intuitive screen where the user updates medical details in minutes. Three behaviours make it work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the last-updated date next to the QR code.&lt;/strong&gt; If the date is months old, the user sees the problem before a responder does. A stale profile should feel uncomfortable to look at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm before every shareable-field change.&lt;/strong&gt; Toggling a field into the emergency view is a privacy decision, not a form edit. A short confirmation dialog — naming exactly what responders will see — prevents accidental exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the QR screen reachable in two taps from launch.&lt;/strong&gt; In a real emergency the owner may be the one showing the code, possibly stressed, possibly with one hand free. Deep navigation is a failure mode here, not an inconvenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you ship a medical QR feature
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The QR payload contains a reference only; scan it with a plain camera app and confirm no medical text appears.&lt;/li&gt;
&lt;li&gt;Sensitive fields are encrypted on the device before any Firestore write, and the key lives in secure storage.&lt;/li&gt;
&lt;li&gt;Firestore rules deny full-profile reads to everyone except the owner, and the responder document holds only the approved subset.&lt;/li&gt;
&lt;li&gt;Updating the profile regenerates the displayed code, and rotating the reference invalidates lost printed cards.&lt;/li&gt;
&lt;li&gt;The dashboard shows the last-updated date and asks for confirmation before a field becomes shareable.&lt;/li&gt;
&lt;li&gt;Test the responder view on a device that has never signed in, with mobile data off and on, because the scan will happen in exactly those conditions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Medical features carry a responsibility that ordinary features do not: the data is sensitive, the moment of use is stressful, and the cost of a leak or a stale record is real. The secure design is not complicated — reference, not record; encrypt before the cloud; owner-only full access; a narrow, user-approved emergency view. Build those four habits in and the QR code becomes what it should be: the fastest safe way to hand a stranger the facts that could save your life.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-secure-qr-medical-profile" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>qrcode</category>
      <category>firebase</category>
      <category>dataencryption</category>
    </item>
    <item>
      <title>Integrating On-Device Local LLMs in Flutter Apps</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Sat, 24 Oct 2026 03:02:30 +0000</pubDate>
      <link>https://dev.to/uzairxdev/integrating-on-device-local-llms-in-flutter-apps-42nn</link>
      <guid>https://dev.to/uzairxdev/integrating-on-device-local-llms-in-flutter-apps-42nn</guid>
      <description>&lt;h1&gt;
  
  
  Integrating On-Device Local LLMs in Flutter Apps
&lt;/h1&gt;

&lt;p&gt;Every app seems to have an AI feature these days. However, the standard approach—sending user data to a cloud API like OpenAI or Anthropic—comes with massive drawbacks: high recurring costs, privacy concerns, and a strict requirement for an internet connection.&lt;/p&gt;

&lt;p&gt;In 2026, the trend has heavily shifted toward &lt;strong&gt;On-Device AI&lt;/strong&gt;. Thanks to quantized models (like Llama 3 Mobile or Gemma-Nano) and powerful NPUs (Neural Processing Units) in modern smartphones, we can now run complex LLMs entirely offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go Local?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Absolute Privacy:&lt;/strong&gt; User data never leaves the device. This is critical for healthcare, finance, and journaling apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero API Costs:&lt;/strong&gt; You don't pay per token. The user's device provides the compute power.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline Capabilities:&lt;/strong&gt; The AI works even in airplane mode.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to Do It in Flutter
&lt;/h2&gt;

&lt;p&gt;Running an LLM locally requires bridging native C++ inference engines (like &lt;code&gt;llama.cpp&lt;/code&gt; or &lt;code&gt;MediaPipe&lt;/code&gt;) with Flutter via FFI (Foreign Function Interface) or Platform Channels.&lt;/p&gt;

&lt;p&gt;Here is the general architecture in 2026:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Choose a Quantized Model
&lt;/h3&gt;

&lt;p&gt;You need a model optimized for edge devices, typically quantized to 4-bit (GGUF format) to fit inside a phone's RAM. A 2-billion parameter model usually takes up around 1.5GB of storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The FFI Bridge
&lt;/h3&gt;

&lt;p&gt;Instead of writing messy Platform Channels for iOS and Android separately, developers now rely heavily on &lt;strong&gt;Dart FFI&lt;/strong&gt;. We compile &lt;code&gt;llama.cpp&lt;/code&gt; as a shared library (&lt;code&gt;.so&lt;/code&gt; or &lt;code&gt;.dylib&lt;/code&gt;) and call its functions directly from Dart.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. State Management for Streaming
&lt;/h3&gt;

&lt;p&gt;Inference happens token by token. You need a robust state management solution (like Riverpod or BLoC) to listen to the FFI stream and update your chat UI in real-time without blocking the main isolate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Future is Edge Compute
&lt;/h2&gt;

&lt;p&gt;While cloud LLMs will always be more powerful, the vast majority of mobile app tasks—summarization, tone adjustment, translation, and basic querying—can be handled locally. If you're building a Flutter app with AI in 2026, on-device should be your default consideration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/on-device-local-llms-flutter" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>mobile</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Flutter On-Device AI With TensorFlow Lite</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Sat, 24 Oct 2026 03:01:25 +0000</pubDate>
      <link>https://dev.to/uzairxdev/flutter-on-device-ai-with-tensorflow-lite-4jcj</link>
      <guid>https://dev.to/uzairxdev/flutter-on-device-ai-with-tensorflow-lite-4jcj</guid>
      <description>&lt;p&gt;On-device AI used to sound like a research demo. In 2026 it is a shipping strategy. Run a TensorFlow Lite model directly on the phone, skip the network round trip entirely, and keep the user's most sensitive data on the device that collected it. I did exactly this in &lt;a href="https://www.uzairxdev.online/projects/butter-skin" rel="noopener noreferrer"&gt;Butter Skin&lt;/a&gt;, an AI skincare coach that analyzes skin conditions straight from the camera and returns personalized results in under two seconds — the model never leaves the phone.&lt;/p&gt;

&lt;p&gt;The app runs on Flutter and Dart, uses TensorFlow Lite for the vision model, Riverpod for state management, and Firebase (Auth, Firestore, Cloud Functions) only for account and progress sync — the AI analysis itself is 100% offline. This guide is the practical version of that pipeline: loading a &lt;code&gt;.tflite&lt;/code&gt; model with the &lt;code&gt;tflite_flutter&lt;/code&gt; plugin, preprocessing camera images into the exact tensor the model expects, running inference off the UI thread, and the production habits that keep the feature reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why run the model on the device
&lt;/h2&gt;

&lt;p&gt;Four reasons pushed the Butter Skin analysis on device, and they apply to most mobile AI features:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; Skin photos are about as personal as data gets. On-device inference means they never travel over a network, never sit on a server, and never appear in a request log. Privacy is not a policy page; it is the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; A network round trip for an image upload is measured in seconds. On-device inference on a modern phone is measured in milliseconds, and the whole analysis — capture, preprocess, infer, render — lands under two seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline.&lt;/strong&gt; The feature works in airplane mode, on a poor connection, and in regions where mobile data is expensive. If your feature's value depends on connectivity, that is a choice you are making.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; No per-request billing, no GPU server to keep warm. Once the model ships inside the app bundle, inference is free at any scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: add the dependencies and the model file
&lt;/h2&gt;

&lt;p&gt;You need two packages: &lt;code&gt;tflite_flutter&lt;/code&gt; for the interpreter and &lt;code&gt;image&lt;/code&gt; for decoding and resizing photos without a platform channel.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;assets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;assets/models/skin_model.tflite&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;assets/models/labels.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.tflite&lt;/code&gt; file comes from your own training pipeline — export from TensorFlow and optionally quantize to shrink it. The &lt;code&gt;labels.txt&lt;/code&gt; file holds one class name per line, matching the model's output order. Always verify that the label order matches the training output; a silent mismatch here produces confident, wrong answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: quantize so the model fits the app bundle
&lt;/h2&gt;

&lt;p&gt;Model size directly affects download size and install conversion. A float32 model that is 90 MB becomes roughly 23 MB as int8 quantized — same architecture, quarter of the weight. Quantization converts weights (and optionally activations) from 32-bit floats to 8-bit integers, trading a small, measurable accuracy drop for a large size and speed win.&lt;/p&gt;

&lt;p&gt;Two rules that matter in practice. First, measure accuracy on your own validation set before and after quantization; do not assume the drop is negligible for your task. Second, keep the training-time input preprocessing identical between the float and quantized versions — quantized models are less forgiving of mismatched normalization. If the accuracy drop is unacceptable for your use case, ship the float32 model and accept the larger download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: load the interpreter once
&lt;/h2&gt;

&lt;p&gt;An interpreter is expensive to create and cheap to reuse. Load it at startup, allocate its tensors, and keep it alive for the app's lifetime:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Interpreter&lt;/span&gt; &lt;span class="n"&gt;_interpreter&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;initModel&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InterpreterOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_interpreter&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;Interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'assets/models/skin_model.tflite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allocateTensors&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;Setting &lt;code&gt;threads&lt;/code&gt; lets the interpreter use multiple CPU cores, which is the cheapest speed-up available. The plugin also supports hardware delegates — NNAPI and GPU delegates on Android, Metal and CoreML delegates on iOS, XNNPack on desktop. Start with multi-threaded CPU, measure on real devices, and add a delegate only when profiling says it helps.&lt;/p&gt;

&lt;p&gt;When you do try a delegate, wrap it so older devices fall back gracefully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initModelWithDelegate&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InterpreterOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Add a hardware delegate here when your model benefits from it.&lt;/span&gt;
  &lt;span class="n"&gt;_interpreter&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;Interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'assets/models/skin_model.tflite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;options&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;catchError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Delegate failed on this device: fall back to plain multi-threaded CPU.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InterpreterOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;'assets/models/skin_model.tflite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;fallback&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;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allocateTensors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: preprocess the camera image into the model's input tensor
&lt;/h2&gt;

&lt;p&gt;This is where most on-device AI bugs live. A vision model trained on 224×224 float32 images normalized to 0–1 will produce garbage if you hand it a 1080p photo with raw 0–255 pixels. Decode, resize, normalize:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;preprocessImage&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;imagePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;224&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;bytes&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;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imagePath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readAsBytes&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;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decodeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&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;decoded&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Could not decode image'&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;resized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyResize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;width:&lt;/span&gt; &lt;span class="n"&gt;size&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;size&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;var&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&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="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pixel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resized&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPixel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pixel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;r&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pixel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;g&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pixel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;input&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;Match this code to your own model card: input resolution, channel order (RGB vs BGR), and normalization range (0–1 vs -1–1) are training-time decisions, and the app must reproduce them exactly. The single most common on-device vision bug is a preprocessing mismatch — the model runs fine and the answers are nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: run inference and read the output
&lt;/h2&gt;

&lt;p&gt;Load the labels, allocate an output buffer shaped like the model's output tensor, and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:math'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/services.dart'&lt;/span&gt; &lt;span class="kd"&gt;show&lt;/span&gt; &lt;span class="n"&gt;rootBundle&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_labels&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;initLabels&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;raw&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;rootBundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'assets/models/labels.txt'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&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="se"&gt;\n&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;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;line&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;line&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;analyzeSkin&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;imagePath&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;input&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;preprocessImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imagePath&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;outShape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOutputTensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. [1, 5]&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;outShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;outShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;outShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

  &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&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;_labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;raw.split('\n')&lt;/code&gt; is written with a single backslash in real Dart. In this article it appears doubled because the blog system stores articles in a TypeScript template literal — write it single-backslashed in your own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: move inference off the UI thread
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;interpreter.run&lt;/code&gt; is synchronous and can block for hundreds of milliseconds — enough to drop frames and freeze animations. The &lt;code&gt;tflite_flutter&lt;/code&gt; package ships &lt;code&gt;IsolateInterpreter&lt;/code&gt;, which runs inference in a separate isolate while your UI isolate stays responsive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;IsolateInterpreter&lt;/span&gt; &lt;span class="n"&gt;_isolateInterpreter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_outputShape&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;initIsolateModel&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InterpreterOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_interpreter&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;Interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'assets/models/skin_model.tflite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allocateTensors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Cache the output shape once; inference happens in the other isolate.&lt;/span&gt;
  &lt;span class="n"&gt;_outputShape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOutputTensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_isolateInterpreter&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;IsolateInterpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;address:&lt;/span&gt; &lt;span class="n"&gt;_interpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;address&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;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;analyzeSkinAsync&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;imagePath&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;input&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;preprocessImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imagePath&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_outputShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_outputShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;_outputShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;_outputShape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_isolateInterpreter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&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;_labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;best&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 isolate is created from the interpreter's address and shares its native resources, so keep the original interpreter alive for as long as the isolate exists. Both &lt;code&gt;run&lt;/code&gt; and &lt;code&gt;runForMultipleInputs&lt;/code&gt; on &lt;code&gt;IsolateInterpreter&lt;/code&gt; are asynchronous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: production habits that keep the feature alive
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One interpreter per model.&lt;/strong&gt; Never create an interpreter per photo. It is a singleton for the feature's lifetime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dispose properly.&lt;/strong&gt; Call &lt;code&gt;interpreter.close()&lt;/code&gt; and &lt;code&gt;isolateInterpreter.close()&lt;/code&gt; when the feature is torn down. Leaked interpreters hold native memory the Dart garbage collector cannot see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delegate fallback.&lt;/strong&gt; If you enable a GPU delegate, wrap creation and fall back to multi-threaded CPU. Older devices fail delegate creation in ways you cannot predict from the emulator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure on real hardware.&lt;/strong&gt; TFLite behaves differently per chipset, and per the package docs it does not work reliably in the iOS simulator — test on physical devices, including a low-end Android phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version your models.&lt;/strong&gt; When you retrain, ship the new &lt;code&gt;.tflite&lt;/code&gt; with the app update and keep the labels file in lockstep. A model update without its labels is a silent accuracy regression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with training-like inputs.&lt;/strong&gt; Run your own validation photos through the app build, not just the Python notebook. Camera white balance and JPEG compression differ from curated datasets, and that gap is where real-world accuracy dies.&lt;/p&gt;

&lt;p&gt;In Butter Skin these habits — combined with Riverpod managing the analysis flow and Firebase Cloud Functions syncing the visual progress diary — delivered under-two-second on-device analysis, 99.9% crash-free sessions, and a 4.8-star rating.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the model should stay on the server
&lt;/h2&gt;

&lt;p&gt;On-device is not always the answer. Large language models are too heavy for most phones' power budgets — for those, small language models or server-side APIs are the realistic options (I covered the on-device LLM side in &lt;a href="https://www.uzairxdev.online/blog/on-device-slm-edge-ai" rel="noopener noreferrer"&gt;On-Device SLMs for Flutter&lt;/a&gt;). Rules of thumb: vision and classification models belong on device; generative and reasoning models usually stay behind an API. And keep inference cheap either way — even on device, a model that drains the battery gets uninstalled faster than one that is slightly less accurate. For keeping the rest of the frame budget under control, see &lt;a href="https://www.uzairxdev.online/blog/zero-jank-flutter-performance-profiling" rel="noopener noreferrer"&gt;Zero-Jank Flutter Performance&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;On-device AI in Flutter is no longer exotic — it is a dependency, a model file, and about a hundred lines of Dart. Load the interpreter once, reproduce the training-time preprocessing exactly, run inference in an isolate, and fall back gracefully when hardware acceleration is not available. Your users get instant, private, offline intelligence, and you get a feature with no server bill attached.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-on-device-ai-tensorflow-lite" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>tensorflowlite</category>
      <category>ondeviceai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Architecting Offline-First Mobile Apps: Resilient Sync Engines &amp; Conflict Resolution</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Fri, 23 Oct 2026 03:01:25 +0000</pubDate>
      <link>https://dev.to/uzairxdev/architecting-offline-first-mobile-apps-resilient-sync-engines-conflict-resolution-3ol7</link>
      <guid>https://dev.to/uzairxdev/architecting-offline-first-mobile-apps-resilient-sync-engines-conflict-resolution-3ol7</guid>
      <description>&lt;h1&gt;
  
  
  Architecting Offline-First Mobile Apps: Resilient Sync Engines &amp;amp; Conflict Resolution
&lt;/h1&gt;

&lt;p&gt;In the world of mobile engineering, assuming an always-on, high-speed fiber connection is the quickest path to a broken user experience. Whether a user is stepping into an elevator, boarding a subway, or driving through a cellular dead zone, networks fail constantly.&lt;/p&gt;

&lt;p&gt;Traditional apps treat offline mode as an edge-case error state, plastering "No Internet Connection" dialogs across the screen. Modern, production-grade applications take the opposite approach: &lt;strong&gt;they are offline-first by default&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let's dissect the architectural blueprint for building resilient, enterprise-grade offline sync engines in cross-platform mobile apps.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Core Philosophy: Local-First as the Single Source of Truth
&lt;/h2&gt;

&lt;p&gt;The golden rule of offline-first design is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The UI never waits for the network. The UI only ever reads from and writes to the local database.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When an end user taps "Submit", "Like", or "Update Profile":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The mutation is immediately committed to the local embedded database (SQLite via Drift, Isar, or Realm).&lt;/li&gt;
&lt;li&gt;The UI reacts instantaneously to the local database stream via reactive state management (BLoC or Riverpod).&lt;/li&gt;
&lt;li&gt;A background sync engine independently dispatches the change to the backend when connectivity allows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This guarantees a zero-latency UI regardless of network conditions.&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;// Pattern: Writing to local database first&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;updateTaskTitle&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;taskId&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;newTitle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Transactionally update local state and queue sync intent&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;localDb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;localDb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tasksDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newTitle&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;localDb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncQueueDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;SyncMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nl"&gt;entity:&lt;/span&gt; &lt;span class="s"&gt;'tasks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;entityId:&lt;/span&gt; &lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;action:&lt;/span&gt; &lt;span class="n"&gt;SyncAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;payload:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'title'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newTitle&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nl"&gt;createdAt:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toUtc&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;// 2. Trigger background worker to flush queue if online&lt;/span&gt;
  &lt;span class="n"&gt;syncEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;triggerSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. The Transactional Outbox Pattern
&lt;/h2&gt;

&lt;p&gt;A common pitfall is updating the local database and subsequently failing to enqueue the network request due to an unhandled exception or app termination.&lt;/p&gt;

&lt;p&gt;To guarantee that no user action is ever lost, use the &lt;strong&gt;Transactional Outbox Pattern&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every write mutation must be wrapped in an atomic database transaction that updates both the entity table and an internal &lt;code&gt;sync_mutations_queue&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;Each mutation in the queue has a unique idempotent UUID (&lt;code&gt;mutation_id&lt;/code&gt;), an &lt;code&gt;attempts&lt;/code&gt; counter, a timestamp, and an execution status (&lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;in_flight&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the device regains connectivity, the sync engine pulls pending mutations ordered by &lt;code&gt;created_at&lt;/code&gt; and flushes them sequentially or in batches.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Moving Beyond "Last-Write-Wins" (LWW)
&lt;/h2&gt;

&lt;p&gt;The simplest conflict resolution strategy is &lt;strong&gt;Last-Write-Wins (LWW)&lt;/strong&gt; based on client timestamps. However, in production, LWW is notorious for silent data loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mobile device clocks suffer from clock skew (often several minutes or even days off).&lt;/li&gt;
&lt;li&gt;A user working offline for two hours could unintentionally overwrite fresher data written by an online teammate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Better Alternatives:
&lt;/h3&gt;

&lt;h3&gt;
  
  
  A. Field-Level Delta Updates
&lt;/h3&gt;

&lt;p&gt;Instead of uploading the entire record payload, only transmit the exact fields modified. If User A updates the description and User B updates the due date, both changes merge cleanly without conflicts.&lt;/p&gt;

&lt;h3&gt;
  
  
  B. Version Vectors &amp;amp; Revisions
&lt;/h3&gt;

&lt;p&gt;Assign an incrementing revision number (&lt;code&gt;rev_id&lt;/code&gt;) or vector clock to each entity. When pushing an update, the client transmits the &lt;code&gt;base_rev_id&lt;/code&gt; it started from. If the server detects that the entity has already advanced to a higher revision, it responds with a &lt;code&gt;409 Conflict&lt;/code&gt;, providing the latest remote state for the client to resolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  C. Conflict-Free Replicated Data Types (CRDTs)
&lt;/h3&gt;

&lt;p&gt;For collaborative tools, rich text notes, and counters, CRDTs are the ultimate modern standard. State-based or Operation-based CRDTs guarantee that multiple replicas converge to the exact same state mathematically, regardless of arrival order or network delays.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Background Sync &amp;amp; Battery Economy
&lt;/h2&gt;

&lt;p&gt;Mobile operating systems aggressively terminate background threads to preserve battery life. To keep your sync pipeline reliable without draining power:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use Platform-Native Schedulers:&lt;/strong&gt; Leverage &lt;code&gt;WorkManager&lt;/code&gt; on Android and &lt;code&gt;BGAppRefreshTask&lt;/code&gt; on iOS (via plugins like &lt;code&gt;workmanager&lt;/code&gt;) for periodic reconciliation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential Backoff with Jitter:&lt;/strong&gt; If the server returns a 5xx error or rate limit, never hammer the API in a tight retry loop. Implement exponential backoff combined with randomized jitter to prevent the "thundering herd" problem when service resumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectivity Debouncing:&lt;/strong&gt; Do not trigger an immediate sync the microsecond the network changes. Debounce network connectivity broadcasts by 1.5 to 2 seconds to allow the radio interface to stabilize.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  5. Architectural Checklist for Production
&lt;/h2&gt;

&lt;p&gt;Before shipping an offline-first mobile app, verify that your implementation satisfies these criteria:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Idempotent APIs:&lt;/strong&gt; The backend recognizes duplicate &lt;code&gt;mutation_id&lt;/code&gt; headers and returns cached success responses.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Reactive Streams:&lt;/strong&gt; The presentation layer subscribes directly to local queries; no manual &lt;code&gt;pullToRefresh&lt;/code&gt; is required to see offline changes.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Secure Storage:&lt;/strong&gt; The local SQLite database is encrypted at rest using SQLCipher (256-bit AES).&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Soft Deletes:&lt;/strong&gt; Entities are marked with &lt;code&gt;is_deleted = true&lt;/code&gt; rather than physically purged, ensuring delete operations replicate across all peers.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Telemetry:&lt;/strong&gt; Monitor sync success rates and queue latency in your analytics to detect edge-case sync stalls in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Offline-first architecture demands higher upfront discipline, but the reward is immense: an app that feels instant, resilient, and virtually indestructible in the hands of real users.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/offline-first-architecture-crdt-mobile" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>offlinefirst</category>
      <category>architecture</category>
      <category>crdt</category>
    </item>
    <item>
      <title>Flutter Breathing Pacer Animation Guide</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Fri, 23 Oct 2026 03:00:12 +0000</pubDate>
      <link>https://dev.to/uzairxdev/flutter-breathing-pacer-animation-guide-5ckk</link>
      <guid>https://dev.to/uzairxdev/flutter-breathing-pacer-animation-guide-5ckk</guid>
      <description>&lt;p&gt;Breathing is the one animation where the user trusts the motion with their body. In my guided breathing and meditation app, &lt;a href="https://www.uzairxdev.online/projects/inhalo" rel="noopener noreferrer"&gt;Inhalo&lt;/a&gt;, the smooth animations and breathing pacers that guide users visually are the core of the product — backed by audio playback for guided meditation tracks and ambient sounds. When someone follows an inhale, the expanding circle on screen becomes their lungs. If it stutters, lags, or drifts off the real four-second count, they do not think "there is a bug" — they feel the exercise is wrong.&lt;/p&gt;

&lt;p&gt;This guide walks through how to build a production-grade breathing pacer in Flutter: a phase-driven animation model, an expanding circle drawn with CustomPainter, haptic and audio cues at phase changes, and the performance habits that keep it locked at 60fps on low-end Android phones. Every snippet targets the current stable Flutter and Dart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the breath cycle as phases
&lt;/h2&gt;

&lt;p&gt;A breathing exercise is a small state machine. Box breathing is the simplest: inhale, hold, exhale, hold — four seconds each. Modeling each phase as data keeps the animation logic, the label, and the cues in one place:&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;BreathPhase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;inhale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holdIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exhale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holdOut&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BreathCycle&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;BreathCycle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phases&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'Box breathing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// Each phase paired with its duration.&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;BreathPhase&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;phases&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;total&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;phases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BreathCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;phases:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inhale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exhale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;4&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;Dart records keep this compact and readable. If you later want 4-7-8 breathing (inhale 4, hold 7, exhale 8), you define a new constant — no animation code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drive the phases with one AnimationController
&lt;/h2&gt;

&lt;p&gt;Use a single &lt;code&gt;AnimationController&lt;/code&gt; whose duration equals the whole cycle, then derive the current phase from the controller's value. This guarantees the visuals can never drift out of sync with the timing, because there is only one source of truth:&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;BreathingPacer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatefulWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BreathingPacer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BreathCycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;box&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;BreathCycle&lt;/span&gt; &lt;span class="n"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BreathingPacer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_BreathingPacerState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_BreathingPacerState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BreathingPacer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;SingleTickerProviderStateMixin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;AnimationController&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;_controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AnimationController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;vsync:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;duration:&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&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;repeat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;/// Which phase the animation is currently in, derived from progress.&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;phaseAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;progress&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;totalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inMilliseconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;totalMs&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phases&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;elapsed&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inMilliseconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inMilliseconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inMilliseconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

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

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;AnimatedBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;animation:&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phaseProgress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phaseAt&lt;/span&gt;&lt;span class="p"&gt;(&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;value&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;_PacerVisual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;phase:&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;phaseProgress:&lt;/span&gt; &lt;span class="n"&gt;phaseProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;cycle:&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&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;Two things matter here. First, &lt;code&gt;AnimatedBuilder&lt;/code&gt; rebuilds only the subtree below it — the rest of your screen never rebuilds on every frame. Second, the phase is pure math over &lt;code&gt;controller.value&lt;/code&gt;, so pausing, seeking, or slowing the controller (for example with a user-controlled speed setting) moves everything in lockstep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the expanding circle with CustomPainter
&lt;/h2&gt;

&lt;p&gt;The signature visual is a circle that grows on inhale, holds, and shrinks on exhale. &lt;code&gt;CustomPainter&lt;/code&gt; gives you full control with almost no overhead:&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;_PacerVisual&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatelessWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_PacerVisual&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phaseProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cycle&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;BreathPhase&lt;/span&gt; &lt;span class="n"&gt;phase&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;double&lt;/span&gt; &lt;span class="n"&gt;phaseProgress&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;BreathCycle&lt;/span&gt; &lt;span class="n"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RepaintBoundary&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;CustomPaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;painter:&lt;/span&gt; &lt;span class="n"&gt;_BreathPainter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;phase:&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;progress:&lt;/span&gt; &lt;span class="n"&gt;phaseProgress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;size:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;square&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;280&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_BreathPainter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;CustomPainter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_BreathPainter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;progress&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;BreathPhase&lt;/span&gt; &lt;span class="n"&gt;phase&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;double&lt;/span&gt; &lt;span class="n"&gt;progress&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;paint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Canvas&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt; &lt;span class="n"&gt;size&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;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Expansion curve: grow on inhale, hold, shrink on exhale, rest.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;expansion&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;phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inhale&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;Curves&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;easeInOut&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdIn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exhale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;Curves&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;easeInOut&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdOut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;maxRadius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shortestSide&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxRadius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.35&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;expansion&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Soft glow ring behind the main circle.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;glow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Paint&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;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFF7FB069&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;alpha:&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;maskFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MaskFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BlurStyle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drawCircle&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="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glow&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;fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Paint&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFF4E6E5D&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drawCircle&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="n"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// A thin inner ring that pulses slightly ahead of the fill.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Paint&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;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFFF5F1E6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;alpha:&lt;/span&gt; &lt;span class="mf"&gt;0.6&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;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PaintingStyle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stroke&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;strokeWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drawCircle&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="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.82&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;expansion&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;shouldRepaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_BreathPainter&lt;/span&gt; &lt;span class="n"&gt;old&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;old&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;progress&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;Wrapping the &lt;code&gt;CustomPaint&lt;/code&gt; in a &lt;code&gt;RepaintBoundary&lt;/code&gt; is the single biggest performance win: the circle repaints in its own layer instead of forcing the parent and siblings to repaint every frame. On older Android devices this is the difference between a smooth 60fps and visible judder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show the phase label without rebuilding the world
&lt;/h2&gt;

&lt;p&gt;The label ("Breathe in", "Hold", "Breathe out") changes only four times per cycle, so it should not ride along with the per-frame rebuild. Derive it in the same &lt;code&gt;AnimatedBuilder&lt;/code&gt; but push it into a &lt;code&gt;ValueNotifier&lt;/code&gt; that only notifies on change:&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;_PacerScreen&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;StatefulWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_PacerScreen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_PacerScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_PacerScreenState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_PacerScreenState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_PacerScreen&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;_label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ValueNotifier&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;'Breathe in'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inhale&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Breathe in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdIn&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Hold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exhale&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Breathe out'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BreathPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;holdOut&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Rest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_onPhase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BreathPhase&lt;/span&gt; &lt;span class="n"&gt;phase&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_label&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;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_label&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;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;HapticFeedback&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lightImpact&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Phase cue, no visual coupling.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

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

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;mainAxisSize:&lt;/span&gt; &lt;span class="n"&gt;MainAxisSize&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;children:&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;BreathingPacer&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;SizedBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;height:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;ValueListenableBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;valueListenable:&lt;/span&gt; &lt;span class="n"&gt;_label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&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;AnimatedSwitcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;duration:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="n"&gt;value&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;ValueKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="nl"&gt;style:&lt;/span&gt; &lt;span class="n"&gt;Theme&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;textTheme&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headlineSmall&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;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;Wire &lt;code&gt;_onPhase&lt;/code&gt; by adding a controller listener inside &lt;code&gt;BreathingPacer&lt;/code&gt; (expose the current phase through a callback) — the pattern above keeps the 60fps painting path completely separate from the text updates. This separation also makes it easy to respect accessibility: check &lt;code&gt;MediaQuery.disableAnimations&lt;/code&gt; and, when it is true, freeze the pacer and present a simple countdown instead of motion. (For more on building inclusive Flutter experiences, see my &lt;a href="https://www.uzairxdev.online/blog/accessibility-in-flutter-guide" rel="noopener noreferrer"&gt;accessibility guide&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Add haptic and audio cues
&lt;/h2&gt;

&lt;p&gt;Haptics make phase changes unmistakable even when the user closes their eyes, which is exactly how breathing exercises are used. &lt;code&gt;HapticFeedback.lightImpact()&lt;/code&gt; at each transition is enough — heavy vibration feels alarming, not calming. A quick guard keeps it from firing when the app is backgrounded:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cuePhaseChange&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Safe to call from a controller listener; no-ops on unsupported devices.&lt;/span&gt;
  &lt;span class="n"&gt;HapticFeedback&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lightImpact&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For audio cues, Inhalo plays guided meditation tracks and ambient sounds alongside the animation. The cleanest approach is to pre-download any audio so it plays offline — I cover that pattern in detail in &lt;a href="https://www.uzairxdev.online/blog/flutter-offline-audio-playback" rel="noopener noreferrer"&gt;Flutter Offline Audio: Cache and Play MP3s&lt;/a&gt;. A short chime at each phase change, started a few hundred milliseconds early, lands exactly when the visual transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep it at 60fps
&lt;/h2&gt;

&lt;p&gt;A breathing pacer runs for minutes at a time, so small inefficiencies compound into heat, battery drain, and dropped frames. The rules I follow in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RepaintBoundary around the CustomPaint.&lt;/strong&gt; Mentioned twice because it is that important — it isolates per-frame repaints from the rest of the widget tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No allocations in &lt;code&gt;paint()&lt;/code&gt;.&lt;/strong&gt; Precompute your &lt;code&gt;Paint&lt;/code&gt; objects as fields on the painter, not inside &lt;code&gt;paint&lt;/code&gt;. The sample above creates them per frame for readability; in production, hoist them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Const constructors everywhere else.&lt;/strong&gt; Anything that does not depend on the animation value should be &lt;code&gt;const&lt;/code&gt; so Flutter skips rebuilding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;Curves&lt;/code&gt; transforms over manual math.&lt;/strong&gt; &lt;code&gt;Curves.easeInOut.transform(progress)&lt;/code&gt; is well-tested and cheap; hand-rolled easing usually introduces frame-to-frame discontinuities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on a real low-end device.&lt;/strong&gt; DevTools' performance overlay shows raster-thread time, which is where CustomPainter work lands. If raster time creeps above ~8ms per frame at 60fps, simplify the blur or the number of drawn layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle backgrounding.&lt;/strong&gt; Listen to &lt;code&gt;AppLifecycleState&lt;/code&gt; and pause the controller when the app is inactive; resume on return. An animation silently running in the background drains battery for nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verify the rhythm
&lt;/h2&gt;

&lt;p&gt;A pacer that looks right but times wrong is worse than no pacer. A simple widget test pins the phase math so future refactors cannot break the timing:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'box breathing phases split the cycle evenly'&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;const&lt;/span&gt; &lt;span class="n"&gt;cycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BreathCycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;box&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;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&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;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;16&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="p"&gt;)&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;duration&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;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;4&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;For the full experience, I also validate with a stopwatch test on the controller: advance the test clock and assert that the phase derived at 25%, 50%, and 75% of the cycle matches inhale, hold, and exhale boundaries. Timing is the feature here — test it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;A great breathing pacer is not a fancy animation — it is a precise timer wearing a calm visual. Model the cycle as phase data, drive everything from one controller, paint the circle in an isolated layer, and keep text, haptics, and audio on their own update paths. Get those separations right and the pacer feels effortless: the user breathes with the circle and never thinks about the code at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-breathing-pacer-animation" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>animations</category>
      <category>custompainter</category>
      <category>performance</category>
    </item>
    <item>
      <title>Flutter Web in 2026: Why WebAssembly Changes Everything</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:32:31 +0000</pubDate>
      <link>https://dev.to/uzairxdev/flutter-web-in-2026-why-webassembly-changes-everything-aee</link>
      <guid>https://dev.to/uzairxdev/flutter-web-in-2026-why-webassembly-changes-everything-aee</guid>
      <description>&lt;h1&gt;
  
  
  The State of Flutter Web in 2026: Why WebAssembly (WASM) Changes Everything
&lt;/h1&gt;

&lt;p&gt;For years, Flutter Web was criticized for large payload sizes, janky scrolling, and poor SEO. It was great for dashboards and internal tools, but if you wanted a highly optimized, consumer-facing website, you reached for React or Next.js. &lt;/p&gt;

&lt;p&gt;In 2026, that narrative has completely flipped, and it’s entirely thanks to the maturity of &lt;strong&gt;WebAssembly (WASM)&lt;/strong&gt; and Dart's &lt;code&gt;wasm-gc\&lt;/code&gt; (Garbage Collection) integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with the Old Flutter Web
&lt;/h2&gt;

&lt;p&gt;Historically, Flutter Web compiled your Dart code into JavaScript. The browser then had to parse, compile, and execute massive JavaScript bundles, while a CanvasKit (WebGL) engine handled the drawing. This dual overhead resulted in noticeably slower Time-to-Interactive (TTI) metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter WebAssembly (WASM)
&lt;/h2&gt;

&lt;p&gt;WASM isn't a new language—it's a highly optimized binary instruction format that runs in the browser at near-native speeds. &lt;/p&gt;

&lt;p&gt;By compiling Dart directly to WASM, we bypass the JavaScript engine almost entirely. The browser executes the binary directly, resulting in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant Startup Times:&lt;/strong&gt; WASM binaries load and execute exponentially faster than parsed JS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buttery Smooth 120fps:&lt;/strong&gt; Dropped frames on complex web animations are virtually eliminated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller Payloads:&lt;/strong&gt; The compiled binary is significantly more compact than minified JavaScript.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why 2026 is the Tipping Point
&lt;/h2&gt;

&lt;p&gt;While WASM support was introduced earlier, 2026 is the year it became the default, stable target for Flutter Web. Browsers universally support &lt;code&gt;wasm-gc\&lt;/code&gt; now, meaning Dart doesn't have to bundle its own heavy garbage collector into the web payload anymore—it hooks directly into the browser's native memory management.&lt;/p&gt;

&lt;p&gt;If you haven't looked at Flutter Web since 2023, you owe it to yourself to compile a project using &lt;code&gt;flutter build web --wasm\&lt;/code&gt;. The speed difference isn't just noticeable; it's revolutionary.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-web-wasm-2026" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>webassembly</category>
      <category>web</category>
    </item>
    <item>
      <title>Flutter Offline Audio: Cache and Play MP3s</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:31:25 +0000</pubDate>
      <link>https://dev.to/uzairxdev/flutter-offline-audio-cache-and-play-mp3s-20c5</link>
      <guid>https://dev.to/uzairxdev/flutter-offline-audio-cache-and-play-mp3s-20c5</guid>
      <description>&lt;p&gt;Offline audio is the difference between an app users trust and an app they abandon. When I built &lt;a href="https://www.uzairxdev.online/projects/punj-surah" rel="noopener noreferrer"&gt;Punj Surah&lt;/a&gt; — my Quran companion app with high-quality MP3 audio recitation, a page-by-page reading view, Qibla Finder, Salah tracker, and Tasbeeh counter — one requirement was non-negotiable: once a recitation is downloaded, it must play anywhere. In a mosque basement, on a flight, in a village with one bar of signal. That offline guarantee helped the app reach 10,000+ downloads while holding a 99.9% crash-free rate.&lt;/p&gt;

&lt;p&gt;This guide walks through the exact pattern I use in production: download MP3s into the app's documents directory, play them from local files with just_audio, cap the cache so it never eats the user's storage, and degrade gracefully when the network dies mid-download. Every snippet below is written for the current stable Flutter and Dart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache first, stream second
&lt;/h2&gt;

&lt;p&gt;The architecture is three layers. A &lt;strong&gt;download manager&lt;/strong&gt; fetches each file exactly once and stores it in the app's documents directory. A &lt;strong&gt;cache layer&lt;/strong&gt; applies a least-recently-used eviction policy that deletes the least-recently-played files when the cache grows past a limit you set. A &lt;strong&gt;player layer&lt;/strong&gt; always plays local files and only touches the network when a file is missing.&lt;/p&gt;

&lt;p&gt;Keep a small manifest — a JSON file or a few key-value entries — mapping each track ID to its filename and a flag for whether it is fully downloaded. Never play a partially downloaded file: if the app is killed mid-download, the manifest will tell you the file is incomplete and it must be fetched again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Download MP3s with progress
&lt;/h2&gt;

&lt;p&gt;Download to a temporary &lt;code&gt;.part\&lt;/code&gt; file first, then rename it when the download completes. That way a crash or a killed app can never leave a half-written file that the player mistakes for a complete one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
import 'dart:io';&lt;/p&gt;

&lt;p&gt;import 'package:http/http.dart' as http;&lt;br&gt;
import 'package:path_provider/path_provider.dart';&lt;/p&gt;

&lt;p&gt;Future downloadTrack(&lt;br&gt;
  String url,&lt;br&gt;
  String fileName, {&lt;br&gt;
  void Function(double progress)? onProgress,&lt;br&gt;
}) async {&lt;br&gt;
  final dir = await getApplicationDocumentsDirectory();&lt;br&gt;
  final file = File('\${dir.path}/audio/$fileName');&lt;/p&gt;

&lt;p&gt;if (await file.exists()) return file; // Already cached.&lt;/p&gt;

&lt;p&gt;final tmp = File('\${file.path}.part');&lt;br&gt;
  final request = http.Request('GET', Uri.parse(url));&lt;br&gt;
  final response = await http.Client().send(request);&lt;/p&gt;

&lt;p&gt;final total = response.contentLength ?? 0;&lt;br&gt;
  final sink = tmp.openWrite();&lt;br&gt;
  int received = 0;&lt;/p&gt;

&lt;p&gt;try {&lt;br&gt;
    await for (final chunk in response.stream) {&lt;br&gt;
      sink.add(chunk);&lt;br&gt;
      received += chunk.length;&lt;br&gt;
      if (total &amp;gt; 0) onProgress?.call(received / total);&lt;br&gt;
    }&lt;br&gt;
    await sink.flush();&lt;br&gt;
    await sink.close();&lt;br&gt;
    return await tmp.rename(file.path); // Atomic: complete file only.&lt;br&gt;
  } catch (_) {&lt;br&gt;
    await sink.close();&lt;br&gt;
    if (await tmp.exists()) await tmp.delete();&lt;br&gt;
    rethrow;&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two details matter here. First, &lt;code&gt;contentLength\&lt;/code&gt; can be absent for chunked responses, so guard the progress math. Second, always delete the &lt;code&gt;.part\&lt;/code&gt; file on failure — stale partial files are the classic cause of a player that "plays" silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Play from the local cache
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;just_audio\&lt;/code&gt; can play a local file path directly, which gives you instant start, seeking, and zero buffering:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
import 'package:just_audio/just_audio.dart';&lt;/p&gt;

&lt;p&gt;final player = AudioPlayer();&lt;/p&gt;

&lt;p&gt;Future playCachedTrack(File file) async {&lt;br&gt;
  await player.stop();&lt;br&gt;
  await player.setFilePath(file.path);&lt;br&gt;
  await player.play();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/override"&gt;@override&lt;/a&gt;&lt;br&gt;
void dispose() {&lt;br&gt;
  player.dispose();&lt;br&gt;
  super.dispose();&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Always dispose the player. An &lt;code&gt;AudioPlayer\&lt;/code&gt; holds native audio resources, and leaking one per screen visit is a slow memory drain that the profiler makes obvious but users notice first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fall back to streaming when a file is not cached
&lt;/h2&gt;

&lt;p&gt;The caller decides the strategy: try the cache, and if the file is missing, download it in the background and play it; if the download itself fails with a &lt;code&gt;SocketException\&lt;/code&gt;, stream the URL directly as a last resort.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
Future playTrack(String trackId, String url, String fileName) async {&lt;br&gt;
  final file = File('\${(await getApplicationDocumentsDirectory()).path}/audio/$fileName');&lt;/p&gt;

&lt;p&gt;if (await file.exists()) {&lt;br&gt;
    await playCachedTrack(file);&lt;br&gt;
    return;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;try {&lt;br&gt;
    final downloaded = await downloadTrack(url, fileName);&lt;br&gt;
    await playCachedTrack(downloaded);&lt;br&gt;
  } on SocketException {&lt;br&gt;
    // No network at all: stream it instead, it may still buffer.&lt;br&gt;
    await player.setUrl(url);&lt;br&gt;
    await player.play();&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is the graceful-degradation ladder that keeps an audio app usable on terrible networks: cached file, then fresh download, then raw streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keep the cache under a storage budget
&lt;/h2&gt;

&lt;p&gt;Cached audio accumulates fast — a single Surah recitation can be 15 MB. Pick a budget (say 500 MB), and evict the least-recently-played files whenever you cross it. Run the trim after every completed download.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class _CacheEntry {&lt;br&gt;
  final File file;&lt;br&gt;
  final int size;&lt;br&gt;
  final DateTime modified;&lt;br&gt;
  _CacheEntry({required this.file, required this.size, required this.modified});&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Future trimCache(Directory audioDir, {required int maxBytes}) async {&lt;br&gt;
  final entries = &amp;lt;_CacheEntry&amp;gt;[];&lt;br&gt;
  int total = 0;&lt;/p&gt;

&lt;p&gt;await for (final entity in audioDir.list()) {&lt;br&gt;
    if (entity is! File || entity.path.endsWith('.part')) continue;&lt;br&gt;
    final stat = await entity.stat();&lt;br&gt;
    entries.add(_CacheEntry(file: entity, size: stat.size, modified: stat.modified));&lt;br&gt;
    total += stat.size;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;if (total &amp;lt;= maxBytes) return;&lt;/p&gt;

&lt;p&gt;entries.sort((a, b) =&amp;gt; a.modified.compareTo(b.modified)); // Oldest first.&lt;br&gt;
  for (final entry in entries) {&lt;br&gt;
    await entry.file.delete();&lt;br&gt;
    total -= entry.size;&lt;br&gt;
    if (total &amp;lt;= maxBytes) break;&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note the two guards: &lt;code&gt;.part\&lt;/code&gt; files are skipped so an in-progress download never gets evicted mid-write, and the currently playing file keeps working because just_audio already has it open — deleting the file handle path on Android does not kill the active playback session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: A seek bar that feels instant
&lt;/h2&gt;

&lt;p&gt;With local files, seeking is nearly free. Wire the player's streams into a slider so the UI stays in sync:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
StreamBuilder(&lt;br&gt;
  stream: player.positionStream,&lt;br&gt;
  builder: (context, snapshot) {&lt;br&gt;
    final position = snapshot.data ?? Duration.zero;&lt;br&gt;
    final duration = player.duration ?? Duration.zero;&lt;br&gt;
    final max = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return Slider(
  value: position.inMilliseconds.toDouble().clamp(0.0, max),
  max: max,
  onChanged: (value) {
    player.seek(Duration(milliseconds: value.round()));
  },
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;},&lt;br&gt;
);&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Clamping the value protects against the brief moment when &lt;code&gt;position\&lt;/code&gt; arrives before &lt;code&gt;duration\&lt;/code&gt; — without it, the slider throws a range error on the very first frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set the audio session.&lt;/strong&gt; Use the &lt;code&gt;audio_session\&lt;/code&gt; package to configure the platform session (for example, ducking on Android and the silent-switch behavior on iOS). Players that skip this get one-star reviews from users whose recitation stops when a notification arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background playback needs manifest entries.&lt;/strong&gt; On iOS, add &lt;code&gt;audio\&lt;/code&gt; to &lt;code&gt;UIBackgroundModes\&lt;/code&gt; in &lt;code&gt;Info.plist\&lt;/code&gt;; on Android, use a foreground service with the &lt;code&gt;mediaPlayback\&lt;/code&gt; type for Android 14+. Without them, playback stops the moment the screen locks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not request media permissions you do not need.&lt;/strong&gt; Reading the user's own music library on Android 13+ needs &lt;code&gt;READ_MEDIA_AUDIO\&lt;/code&gt;, but files your app downloaded into its own documents directory need no permission at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the kill-during-download case.&lt;/strong&gt; Force-quit the app mid-download, reopen it, and confirm the track re-downloads instead of playing silence. Your manifest flag is what makes this work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile on a low-end device.&lt;/strong&gt; Local playback is cheap, but a 20 MB file decoded while the UI also renders a waveform animation can still jank on older phones. Check it with the &lt;a href="https://www.uzairxdev.online/blog/zero-jank-flutter-performance-profiling" rel="noopener noreferrer"&gt;DevTools performance view&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Offline audio is one of those features users never praise and always notice when it is missing. Build the cache layer once, and every audio app you ship afterward — recitations, podcasts, language lessons — gets it for free.&lt;/p&gt;

&lt;p&gt;If you are building an audio feature and want it done right the first time, &lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-offline-audio-playback" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>audio</category>
      <category>justaudio</category>
      <category>caching</category>
    </item>
    <item>
      <title>Automating Flutter Workflows: Why CI/CD Is Non-Negotiable</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:00:26 +0000</pubDate>
      <link>https://dev.to/uzairxdev/automating-flutter-workflows-why-cicd-is-non-negotiable-50d3</link>
      <guid>https://dev.to/uzairxdev/automating-flutter-workflows-why-cicd-is-non-negotiable-50d3</guid>
      <description>&lt;h1&gt;
  
  
  Automating Flutter Workflows: Why CI/CD is Non-Negotiable in 2026
&lt;/h1&gt;

&lt;p&gt;If you are still running &lt;code&gt;flutter build apk\&lt;/code&gt; or &lt;code&gt;flutter build ipa\&lt;/code&gt; on your local machine to distribute builds, you are losing hours of valuable engineering time every week. &lt;/p&gt;

&lt;p&gt;In 2026, the complexity of mobile apps demands a robust Continuous Integration and Continuous Deployment (CI/CD) pipeline. It is no longer a luxury reserved for enterprise teams; it is a fundamental requirement for any serious mobile product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Local Builds
&lt;/h2&gt;

&lt;p&gt;Local builds are inherently flaky. Your local environment might have a specific version of Xcode, a cached dependency, or a unique environment variable that your colleague doesn't have. This leads to the infamous "It works on my machine" syndrome. Moreover, generating a release build ties up your machine, completely breaking your flow state.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Automated Testing (CI)
&lt;/h2&gt;

&lt;p&gt;The foundation of a good pipeline is automated testing. Every time a developer pushes code or opens a Pull Request, the CI server should immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;flutter analyze\&lt;/code&gt; to catch linting errors.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;flutter test\&lt;/code&gt; to ensure no business logic is broken.&lt;/li&gt;
&lt;li&gt;Verify that formatting is consistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these fail, the PR cannot be merged. This acts as a quality gate, keeping your main branch pristine.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Automated Distribution (CD)
&lt;/h2&gt;

&lt;p&gt;Once code is merged into the main branch, your CD pipeline takes over. Tools like &lt;strong&gt;Codemagic&lt;/strong&gt;, &lt;strong&gt;Bitrise&lt;/strong&gt;, or &lt;strong&gt;GitHub Actions&lt;/strong&gt; (paired with Fastlane) can automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Increment the build number.&lt;/li&gt;
&lt;li&gt;Sign the application with the correct certificates.&lt;/li&gt;
&lt;li&gt;Build the Android App Bundle (AAB) and iOS Archive (IPA).&lt;/li&gt;
&lt;li&gt;Upload the builds to TestFlight and Google Play Console.&lt;/li&gt;
&lt;li&gt;Notify the team in Slack or Discord.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Choosing the Right Tool
&lt;/h2&gt;

&lt;p&gt;For Flutter developers, there are several fantastic options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codemagic:&lt;/strong&gt; Purpose-built for Flutter. It is incredibly easy to set up and requires minimal configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitrise:&lt;/strong&gt; Excellent visual workflow editor and a massive library of integration steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions:&lt;/strong&gt; The most flexible option, especially if your codebase is already on GitHub. It requires more manual setup (often writing bash scripts or configuring Fastlane) but offers unmatched customization.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Setting up CI/CD might take a few hours upfront, but it pays dividends forever. It removes the stress of deployment, eliminates human error in the release process, and allows you to focus on what actually matters: building a great product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/automating-flutter-workflows-cicd-2026" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>cicd</category>
      <category>devops</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Build a Health Vitals Tracker in Flutter</title>
      <dc:creator>Muhammad Uzair</dc:creator>
      <pubDate>Wed, 21 Oct 2026 02:59:21 +0000</pubDate>
      <link>https://dev.to/uzairxdev/build-a-health-vitals-tracker-in-flutter-5g80</link>
      <guid>https://dev.to/uzairxdev/build-a-health-vitals-tracker-in-flutter-5g80</guid>
      <description>&lt;p&gt;Most health apps fail for the same boring reason: logging a reading takes too long. A blood pressure entry that needs five screens, three keyboards, and a login form will be abandoned by day three — and a tracker nobody opens is a tracker that never helps.&lt;/p&gt;

&lt;p&gt;When I built &lt;a href="https://www.uzairxdev.online/projects/vitals-tracker" rel="noopener noreferrer"&gt;Track Health &amp;amp; Vitals Monitor&lt;/a&gt;, the design target was brutal and specific: log blood pressure, heart rate, and key vitals in seconds, one-handed, straight from the home screen. Families needed separate profiles for each member so every reading stays organized, trends had to be obvious at a glance, and daily reminders had to make sure readings never get missed. This guide distills the Flutter patterns that made that work: the entry flow, the data model, offline-first storage, trend charts, and reminders that survive Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the entry flow for speed
&lt;/h2&gt;

&lt;p&gt;The home screen of a vitals app should be one thing: a fast path to a saved reading. Two number fields (systolic and diastolic), an optional heart rate field, and a save button — that is the whole flow. Everything else (notes, editing history, settings) lives behind it.&lt;/p&gt;

&lt;p&gt;Three details make or break the speed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Number keyboards only.&lt;/strong&gt; Set &lt;code&gt;keyboardType\&lt;/code&gt; to &lt;code&gt;TextInputType.number\&lt;/code&gt; and add &lt;code&gt;FilteringTextInputFormatter.digitsOnly\&lt;/code&gt; so users never fight a full keyboard for a number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move focus automatically.&lt;/strong&gt; Set &lt;code&gt;textInputAction: TextInputAction.next\&lt;/code&gt; on the first field and &lt;code&gt;done\&lt;/code&gt; on the last, so the keyboard itself walks the user through the flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-fill everything.&lt;/strong&gt; The timestamp defaults to now, and the values default to the last reading. Most days the user changes nothing except tapping save.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class VitalsInputRow extends StatelessWidget {&lt;br&gt;
  final TextEditingController systolic;&lt;br&gt;
  final TextEditingController diastolic;&lt;/p&gt;

&lt;p&gt;const VitalsInputRow({required this.systolic, required this.diastolic});&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/override"&gt;@override&lt;/a&gt;&lt;br&gt;
  Widget build(BuildContext context) {&lt;br&gt;
    return Row(&lt;br&gt;
      children: [&lt;br&gt;
        Expanded(&lt;br&gt;
          child: TextField(&lt;br&gt;
            controller: systolic,&lt;br&gt;
            autofocus: true,&lt;br&gt;
            keyboardType: TextInputType.number,&lt;br&gt;
            textInputAction: TextInputAction.next,&lt;br&gt;
            inputFormatters: [FilteringTextInputFormatter.digitsOnly],&lt;br&gt;
            decoration: const InputDecoration(labelText: 'Systolic'),&lt;br&gt;
          ),&lt;br&gt;
        ),&lt;br&gt;
        const SizedBox(width: 12),&lt;br&gt;
        Expanded(&lt;br&gt;
          child: TextField(&lt;br&gt;
            controller: diastolic,&lt;br&gt;
            keyboardType: TextInputType.number,&lt;br&gt;
            textInputAction: TextInputAction.done,&lt;br&gt;
            inputFormatters: [FilteringTextInputFormatter.digitsOnly],&lt;br&gt;
            decoration: const InputDecoration(labelText: 'Diastolic'),&lt;br&gt;
          ),&lt;br&gt;
        ),&lt;br&gt;
      ],&lt;br&gt;
    );&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two large fields side by side fit a thumb, and with &lt;code&gt;autofocus\&lt;/code&gt; the keyboard is already up when the screen opens. That alone cuts the flow to two taps and a save.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model readings per profile
&lt;/h2&gt;

&lt;p&gt;A family tracker is really a multi-user app wearing a single-user disguise. The cleanest way to keep it sane: every reading carries a &lt;code&gt;profileId\&lt;/code&gt;, and the app holds one active profile in state. Every query filters by the active profile, so each member gets an independent history without any data ever leaking across.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
class VitalsReading {&lt;br&gt;
  final String id;&lt;br&gt;
  final String profileId;&lt;br&gt;
  final DateTime takenAt;&lt;br&gt;
  final int systolic;&lt;br&gt;
  final int diastolic;&lt;br&gt;
  final int? heartRate;&lt;/p&gt;

&lt;p&gt;const VitalsReading({&lt;br&gt;
    required this.id,&lt;br&gt;
    required this.profileId,&lt;br&gt;
    required this.takenAt,&lt;br&gt;
    required this.systolic,&lt;br&gt;
    required this.diastolic,&lt;br&gt;
    this.heartRate,&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;Map toJson() =&amp;gt; {&lt;br&gt;
        'id': id,&lt;br&gt;
        'profileId': profileId,&lt;br&gt;
        'takenAt': takenAt.toIso8601String(),&lt;br&gt;
        'systolic': systolic,&lt;br&gt;
        'diastolic': diastolic,&lt;br&gt;
        'heartRate': heartRate,&lt;br&gt;
      };&lt;/p&gt;

&lt;p&gt;factory VitalsReading.fromJson(Map json) =&amp;gt; VitalsReading(&lt;br&gt;
        id: json['id'] as String,&lt;br&gt;
        profileId: json['profileId'] as String,&lt;br&gt;
        takenAt: DateTime.parse(json['takenAt'] as String),&lt;br&gt;
        systolic: json['systolic'] as int,&lt;br&gt;
        diastolic: json['diastolic'] as int,&lt;br&gt;
        heartRate: json['heartRate'] as int?,&lt;br&gt;
      );&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Keep the active profile in a simple state object (Provider works fine here — one &lt;code&gt;ChangeNotifier\&lt;/code&gt; with &lt;code&gt;activeProfileId\&lt;/code&gt; and a setter). When the user switches profiles, the reading list, charts, and reminders all re-filter from the same source of truth. If you are separating network code from widgets the way a clean data layer demands, the &lt;a href="https://www.uzairxdev.online/blog/flutter-repository-pattern-stop-putting-api-calls-in-widgets" rel="noopener noreferrer"&gt;repository pattern keeps API calls out of widgets&lt;/a&gt; and makes this filtering trivial to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store locally first, sync later
&lt;/h2&gt;

&lt;p&gt;A health reading must never depend on the network. Hive is the right default here: writes are synchronous-feeling, reads are instant, and there is no schema migration ceremony for a simple model. Firebase stays optional — a sync layer you add later, not a gate in front of saving.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`dart&lt;br&gt;
Future saveReading(VitalsReading reading) async {&lt;br&gt;
  final box = await Hive.openBox('readings');&lt;br&gt;
  await box.put(reading.id, reading.toJson());&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Future&amp;gt; readingsFor(String profileId) async {&lt;br&gt;
  final box = await Hive.openBox('readings');&lt;br&gt;
  final all = box.values&lt;br&gt;
      .map((e) =&amp;gt; VitalsReading.fromJson(Map.from(e as Map)))&lt;br&gt;
      .where((r) =&amp;gt; r.profileId == profileId)&lt;br&gt;
      .toList();&lt;br&gt;
  all.sort((a, b) =&amp;gt; b.takenAt.compareTo(a.takenAt));&lt;br&gt;
  return all;&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Open the box once at startup rather than per call in production — repeated &lt;code&gt;openBox\&lt;/code&gt; calls are cheap but sloppy. Storing plain JSON maps instead of generated Hive type adapters keeps the model readable and lets a future sync layer serialize the same payload straight to Firestore. Health data staying on the device by default is also the right privacy posture: nothing leaves the phone unless the user opts into sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make trends obvious at a glance
&lt;/h2&gt;

&lt;p&gt;A list of numbers does not change behavior; a picture does. The trend view should answer one question in under a second: "am I getting better or worse?" That means a 7-day rolling average line, not a scatter of raw points, with background bands for the standard blood-pressure zones (normal, elevated, high) so a reading's meaning is visible without interpreting numbers.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;fl_chart\&lt;/code&gt; package handles this well. Feed it one &lt;code&gt;FlSpot\&lt;/code&gt; per day with the rolling average, and shade the area under the line:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
LineChart(&lt;br&gt;
  LineChartData(&lt;br&gt;
    minY: 60,&lt;br&gt;
    maxY: 180,&lt;br&gt;
    lineBarsData: [&lt;br&gt;
      LineChartBarData(&lt;br&gt;
        spots: weeklyAverages, // List&amp;lt;FlSpot&amp;gt;: x = day index, y = avg systolic&lt;br&gt;
        isCurved: true,&lt;br&gt;
        barWidth: 3,&lt;br&gt;
        dotData: const FlDotData(show: false),&lt;br&gt;
        belowBarData: BarAreaData(&lt;br&gt;
          show: true,&lt;br&gt;
          gradient: LinearGradient(&lt;br&gt;
            colors: [Colors.red.withOpacity(0.25), Colors.transparent],&lt;br&gt;
          ),&lt;br&gt;
        ),&lt;br&gt;
      ),&lt;br&gt;
    ],&lt;br&gt;
    titlesData: const FlTitlesData(&lt;br&gt;
      leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true)),&lt;br&gt;
      bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: true)),&lt;br&gt;
    ),&lt;br&gt;
    gridData: const FlGridData(show: true),&lt;br&gt;
  ),&lt;br&gt;
)&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Compute the rolling averages in Dart before touching the widget — a small pure function over the sorted readings list that is easy to unit test. Keep the chart dumb: it renders spots, nothing more. A second line for diastolic uses the same pattern with a different color, and the zone bands become simple horizontal reference lines via &lt;code&gt;ExtraLinesData\&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reminders that survive Android
&lt;/h2&gt;

&lt;p&gt;Daily reminders are the feature that keeps the streak alive, and they are also the easiest to get wrong on Android. Doze mode delays inexact alarms, so a reminder scheduled with a naive timer arrives whenever the OS feels like it. Use &lt;code&gt;flutter_local_notifications\&lt;/code&gt; with exact scheduling and match the time-of-day component so it repeats daily:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;dart&lt;br&gt;
await flutterLocalNotificationsPlugin.zonedSchedule(&lt;br&gt;
  0,&lt;br&gt;
  'Time for your reading',&lt;br&gt;
  'Log today\u2019s vitals in under 10 seconds.',&lt;br&gt;
  _nextInstanceOfToday(9, 0),&lt;br&gt;
  const NotificationDetails(&lt;br&gt;
    android: AndroidNotificationDetails(&lt;br&gt;
      'vitals_daily',&lt;br&gt;
      'Daily reminders',&lt;br&gt;
      importance: Importance.high,&lt;br&gt;
    ),&lt;br&gt;
  ),&lt;br&gt;
  androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,&lt;br&gt;
  matchDateTimeComponents: DateTimeComponents.time,&lt;br&gt;
);&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two things this needs to actually work on modern Android: the &lt;code&gt;SCHEDULE_EXACT_ALARM\&lt;/code&gt; permission declared and requested at runtime, and timezone initialization (&lt;code&gt;tz.initializeTimeZones()\&lt;/code&gt;) before computing &lt;code&gt;-nextInstanceOfToday\&lt;/code&gt;. The full exact-alarm setup — permission flows, the &lt;code&gt;canScheduleExactAlarms\&lt;/code&gt; check, and the Android 14+ behavior changes — is covered in depth in &lt;a href="https://www.uzairxdev.online/blog/flutter-exact-alarm-scheduling-android" rel="noopener noreferrer"&gt;Flutter Exact Alarm Scheduling on Android&lt;/a&gt;, which walks through the same machinery this reminder relies on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate like a clinician is watching
&lt;/h2&gt;

&lt;p&gt;Health apps earn trust by refusing bad data politely. Validate every entry before it touches storage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Physiological ranges.&lt;/strong&gt; Systolic 70–300, diastolic 40–200, heart rate 30–220. Reject anything outside with a specific message ("Systolic must be between 70 and 300"), not a generic "invalid input".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No future timestamps.&lt;/strong&gt; A reading dated tomorrow corrupts every trend chart. Clamp to now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm destructive actions.&lt;/strong&gt; Deleting a reading needs an explicit confirm dialog — health history is not the place for swipe-to-delete without a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Systolic above diastolic.&lt;/strong&gt; If the user swaps the fields, say so immediately instead of storing nonsense.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous, but it is what separates an app people trust with their health from a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship the boring parts well
&lt;/h2&gt;

&lt;p&gt;A vitals tracker is not a showcase for exotic architecture. It is a showcase for discipline: a sub-10-second entry flow, one reading model with a &lt;code&gt;profileId\&lt;/code&gt;, local-first storage, charts that answer "am I improving?" at a glance, and reminders that fire on time. Get those right and the app does its job quietly, every day — which is exactly what a health app should do.&lt;/p&gt;

&lt;p&gt;If you are building a health or data-entry heavy Flutter app and want the logging flow, offline storage, and reminder scheduling done right the first time, &lt;a href="https://www.uzairxdev.online/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and let's talk about your project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.uzairxdev.online/blog/flutter-health-vitals-tracker" rel="noopener noreferrer"&gt;uzairxdev.online&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>health</category>
      <category>hive</category>
      <category>flchart</category>
    </item>
  </channel>
</rss>
