<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Gidudu Nicholas]]></title><description><![CDATA[Nicholas is a Flutter & Dart expert, open-source contributor, and community builder dedicated to empowering developers across Africa. As the lead organizer of Flutter Kampala and GDG Bugiri, he has impacted hundreds of developers through talks, mentorship, and hands-on learning. His work spans scalable mobile architecture, backend systems with Serverpod, and fintech solutions.]]></description><link>https://gidudunicholas.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/5e6a520daf89662115c0ea14/b492bcef-8ac9-442a-8d5c-a85c31c70b38.jpg</url><title>Gidudu Nicholas</title><link>https://gidudunicholas.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 03:42:20 GMT</lastBuildDate><atom:link href="https://gidudunicholas.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Hidden Cost of Rebuilds in Flutter]]></title><description><![CDATA[Imagine you're in your house and you turn on the light in your bedroom — but somehow every single light in the house comes on: the kitchen, the bathroom, even the one outside that nobody uses. Nothing]]></description><link>https://gidudunicholas.dev/the-hidden-cost-of-rebuilds-in-flutter</link><guid isPermaLink="true">https://gidudunicholas.dev/the-hidden-cost-of-rebuilds-in-flutter</guid><category><![CDATA[Rebuild ]]></category><category><![CDATA[Flutter]]></category><category><![CDATA[performance]]></category><category><![CDATA[Dart]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Sun, 19 Apr 2026 07:55:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5e6a520daf89662115c0ea14/280fcf38-b3db-4cb9-a061-97b994289fc1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Imagine you're in your house and you turn on the light in your bedroom — but somehow every single light in the house comes on: the kitchen, the bathroom, even the one outside that nobody uses. Nothing is technically broken. Everything still works. But your electricity bill is quietly crying in the background.</p>
<p>That's exactly what happens in a Flutter app when rebuilds aren't controlled. One small state change ends up doing far more work than it needs to, and the app doesn't crash or throw an error to tell you. It just gets a little heavier, a little less smooth, one screen at a time.</p>
<h2>"Rebuilds are cheap" is true, and also the wrong thing to optimize for</h2>
<p>You've probably heard this line, and it's correct: Flutter is built to make rebuilding a widget fast. The framework diffs the new widget configuration against the old one and only touches the render objects that actually changed. A single rebuild, in isolation, costs almost nothing.</p>
<p>The part that gets left out is the word <em>unnecessary</em>. Rebuilds are cheap — until you're doing thousands of them for state that thirty widgets on screen don't actually care about. The cost isn't in any one rebuild; it's in the blast radius.</p>
<h2>Seeing the blast radius, not just describing it</h2>
<p>Here's a screen with a counter and a list of ten static "activity" items sitting below it:</p>
<pre><code class="language-dart">class DashboardScreen extends StatefulWidget {
  const DashboardScreen({super.key});
  @override
  State&lt;DashboardScreen&gt; createState() =&gt; _DashboardScreenState();
}

class _DashboardScreenState extends State&lt;DashboardScreen&gt; {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    debugPrint('DashboardScreen build');
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          Text('Count: $count'),
          ElevatedButton(
            onPressed: () =&gt; setState(() =&gt; count++),
            child: const Text('Increment'),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: 10,
              itemBuilder: (context, i) {
                debugPrint('ActivityTile $i build');
                return ActivityTile(index: i);
              },
            ),
          ),
        ],
      ),
    );
  }
}
</code></pre>
<p>Tap the button once, and the console prints <code>DashboardScreen build</code> followed by all ten <code>ActivityTile</code> builds — every time, even though none of those tiles depend on <code>count</code> in any way. The <code>setState</code> call lives in <code>_DashboardScreenState</code>, which sits above the entire <code>Column</code>, so every rebuild of that state re-runs <code>build()</code> for everything below it. Flutter isn't being wasteful here; it's doing exactly what you told it to do. The waste is in where the state was declared, not in how Flutter handles it.</p>
<p>Now move the counter's state down into its own small widget, so the parent no longer owns it:</p>
<pre><code class="language-dart">class DashboardScreen extends StatelessWidget {
  const DashboardScreen({super.key});

  @override
  Widget build(BuildContext context) {
    debugPrint('DashboardScreen build'); // now prints exactly once, ever
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          const CounterWidget(), // owns its own state
          Expanded(
            child: ListView.builder(
              itemCount: 10,
              itemBuilder: (context, i) =&gt; ActivityTile(index: i),
            ),
          ),
        ],
      ),
    );
  }
}

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});
  @override
  State&lt;CounterWidget&gt; createState() =&gt; _CounterWidgetState();
}

class _CounterWidgetState extends State&lt;CounterWidget&gt; {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    debugPrint('CounterWidget build');
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () =&gt; setState(() =&gt; count++),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}
</code></pre>
<p>Tap the button now, and the console prints only <code>CounterWidget build</code>. <code>DashboardScreen</code> never re-runs, and the ten <code>ActivityTile</code> widgets never re-run either — not because Flutter got smarter, but because the dirty element is now three levels lower and nothing above or beside it depends on that state. Same feature, same UI, one line of debug output instead of eleven. That's the entire fix: <strong>the size of a rebuild is decided by where you put the</strong> <code>setState</code><strong>, not by how big your widget tree is.</strong></p>
<h2><code>const</code> is the fix most lists like this skip</h2>
<p>Marking the two static children <code>const</code> — <code>const CounterWidget()</code>, and the same on any widget you know won't change — tells Flutter that the widget instance is identical to the last one, so it skips rebuilding that subtree entirely rather than rebuilding it and finding no changes. It costs nothing to write and is very often the single highest-leverage change you can make in an existing screen, because it requires no restructuring — you're not moving state anywhere, just telling Flutter about the fact that a given branch is already static.</p>
<h2>The other three tools, actually used</h2>
<p>Naming <code>ValueListenableBuilder</code>, <code>Selector</code>, and <code>select</code> without showing them is not much more useful than not naming them at all, so here's each one doing the specific job it exists for.</p>
<p><code>ValueListenableBuilder</code> scopes a rebuild to exactly the widget that displays a value, with no external package:</p>
<pre><code class="language-dart">final ValueNotifier&lt;int&gt; countNotifier = ValueNotifier(0);

ValueListenableBuilder&lt;int&gt;(
  valueListenable: countNotifier,
  builder: (context, value, child) =&gt; Text('Count: $value'),
)
</code></pre>
<p>Only the <code>Text</code> inside the builder rebuilds when <code>countNotifier.value</code> changes — the parent widget never re-runs.</p>
<p><code>Selector</code> (from the <code>provider</code> package) does the same thing for a value pulled out of a larger model, so a widget doesn't rebuild every time <em>any</em> field on that model changes — only when the specific field it selected does:</p>
<pre><code class="language-dart">Selector&lt;CartModel, int&gt;(
  selector: (context, cart) =&gt; cart.itemCount,
  builder: (context, itemCount, child) =&gt; Text('$itemCount items'),
)
</code></pre>
<p>Even if <code>CartModel</code> also tracks a total price, shipping address, and discount code, this widget only rebuilds when <code>itemCount</code> specifically changes.</p>
<p><strong>Riverpod's</strong> <code>select</code> applies the identical idea to a Riverpod provider:</p>
<pre><code class="language-dart">final itemCount = ref.watch(cartProvider.select((cart) =&gt; cart.itemCount));
</code></pre>
<p>All three tools solve the same problem from three different ecosystems: instead of watching a whole object and rebuilding on any change to it, you watch the one field you actually render, and Flutter's diffing only fires when that field moves.</p>
<h2>Why this compounds instead of staying flat</h2>
<p>In a five-screen app, a state object sitting one level too high costs you a handful of unnecessary widget rebuilds per tap — cheap enough that you'll never notice. In a fifty-screen app with shared state at the top of the tree, that same pattern means a single update anywhere can walk through dozens of widgets that have nothing to do with the change, and it happens on every interaction, not just one. The mechanism doesn't change as the app grows. Only the blast radius does, which is exactly why this class of problem tends to surface as "the app feels heavier than it used to" months into a project rather than as a bug report on day one — nothing is failing, so nothing points you at the cause.</p>
<h2>The takeaway</h2>
<p>Don't try to eliminate rebuilds — you can't, and you shouldn't want to; they're how Flutter updates the screen at all. Control where they start and how far they spread: keep state as close as possible to the widgets that actually use it, mark static subtrees <code>const</code>, and reach for <code>ValueListenableBuilder</code>, <code>Selector</code>, or <code>select</code> when a widget only cares about one slice of a larger piece of state. A <code>setState</code> call three widgets deep and a <code>setState</code> call at the root of your screen are the same amount of code and completely different amounts of work — the only thing that changed is where you put it.</p>
]]></content:encoded></item><item><title><![CDATA[What Happens When Your Flutter App Stops Being Small
]]></title><description><![CDATA[Flutter is easy to start with. You build a few screens, connect an API, wire up some state management, and everything works. It keeps working for a surprisingly long time — right up until it doesn't.
]]></description><link>https://gidudunicholas.dev/what-happens-when-your-flutter-app-stops-being-small</link><guid isPermaLink="true">https://gidudunicholas.dev/what-happens-when-your-flutter-app-stops-being-small</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[mobile app development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[app development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Clean Architecture]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Tue, 07 Apr 2026 08:15:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5e6a520daf89662115c0ea14/ee514a1e-4285-456c-a4f8-d54a7200c8dc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Flutter is easy to start with. You build a few screens, connect an API, wire up some state management, and everything works. It keeps working for a surprisingly long time — right up until it doesn't.</p>
<p>At some point, without a single dramatic failure, your app stops being small. Builds slow down. A bug that should take ten minutes to trace takes an afternoon. A feature that used to be a quick add now touches six files you didn't expect. Nothing broke. The codebase just got heavy.</p>
<h2>The illusion of simplicity</h2>
<p>What makes Flutter easy early on — you can put a screen, its widgets, and its logic anywhere and it'll compile and run — is the same thing that makes it fragile later, because "anywhere" quietly turns into "everywhere." A structure that works fine for five screens and one or two developers doesn't fail loudly at fifty screens; it fails slowly, in ways that look like the framework getting worse when it's actually the organization getting worse underneath it.</p>
<p>The signs are consistent enough to name: you find yourself asking "where is this logic even coming from?" more often than you used to. State starts behaving in ways you can't fully explain from reading the screen in front of you. A change to one feature quietly breaks another one that had no obvious reason to be affected. All of that points at the same root cause, and it isn't Flutter — it's structure.</p>
<h2>Why layer-based folders stop working</h2>
<p>Almost every Flutter tutorial teaches you to organize by <em>type of thing</em>:</p>
<pre><code class="language-plaintext">lib/
  screens/
    login_screen.dart
    dashboard_screen.dart
    profile_screen.dart
  widgets/
    login_form.dart
    dashboard_card.dart
  services/
    auth_service.dart
    dashboard_service.dart
  models/
    user.dart
    dashboard_data.dart
</code></pre>
<p>This is <em>layer-based</em> structure, and it's genuinely fine for a small app — there just isn't enough code yet for the downside to show up. The downside is this: the logic for a single feature, say authentication, is now split across four unrelated folders. Touching login means opening <code>screens/login_screen.dart</code>, <code>widgets/login_form.dart</code>, <code>services/auth_service.dart</code>, and <code>models/user.dart</code> — four files, in four different places, that all belong to one idea. Multiply that by fifteen features and "where does this logic live" stops being a rhetorical question.</p>
<p><em>Feature-based</em> structure inverts the organizing principle: instead of grouping by type, you group by what the code is <em>for</em>.</p>
<pre><code class="language-plaintext">lib/
  features/
    auth/
      login_screen.dart
      login_form.dart
      auth_repository.dart
      user.dart
    dashboard/
      dashboard_screen.dart
      dashboard_card.dart
      dashboard_repository.dart
      dashboard_data.dart
</code></pre>
<p>Now everything <code>auth</code> needs to change lives in one folder. Deleting the <code>auth</code> feature, understanding its full surface area, or onboarding someone onto just that part of the app all become one-folder problems instead of whole-codebase scavenger hunts. This is the single highest-leverage structural change most growing Flutter apps can make, and it costs nothing but discipline going forward — it's not a rewrite, it's a decision about where new files go.</p>
<h2>State: local first, global only when earned</h2>
<p>The most common state mistake at scale isn't choosing the wrong state management library — it's putting too much state at the top of the app "just in case." A <code>globalUserState</code>, a <code>globalCartState</code>, and a <code>globalUiState</code> sitting at the root all seem convenient until every screen in the app is implicitly coupled to all three, whether it uses them or not.</p>
<p>The better default: state lives inside the feature that owns it, and only gets promoted to something app-wide when at least two unrelated features genuinely need to share it. A dashboard's sort order or expanded/collapsed card state has no business being global. A logged-in user's identity, which auth, profile, and checkout all legitimately need, does. The test isn't "might this be useful elsewhere someday" — it's "does something outside this feature need it right now."</p>
<h2>Decoupling: depend on a contract, not an implementation</h2>
<p>Layer-based folders group things poorly. The deeper problem underneath is when a widget depends directly on a concrete class instead of an abstraction — because now every part of the app that touches auth is glued to exactly one implementation of it, including in tests.</p>
<p>Here's the coupled version, which is the default if you never think about it:</p>
<pre><code class="language-dart">class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        await AuthService().login(email, password); // concrete class, created inline
      },
      child: const Text('Log in'),
    );
  }
}
</code></pre>
<p><code>LoginScreen</code> now only works with a real <code>AuthService</code> that hits a real network. Testing it means either hitting a real backend or reaching for a mocking framework that reaches inside the widget to swap out a hardcoded dependency — exactly the problem dependency injection exists to solve.</p>
<p>The decoupled version depends on an interface instead:</p>
<pre><code class="language-dart">abstract class AuthRepository {
  Future&lt;User&gt; login(String email, String password);
}

class ApiAuthRepository implements AuthRepository {
  ApiAuthRepository(this._client);
  final http.Client _client;

  @override
  Future&lt;User&gt; login(String email, String password) async {
    final response = await _client.post(
      Uri.parse('https://api.example.com/login'),
      body: {'email': email, 'password': password},
    );
    return User.fromJson(jsonDecode(response.body));
  }
}

class FakeAuthRepository implements AuthRepository {
  @override
  Future&lt;User&gt; login(String email, String password) async {
    return User(id: 'test-user', name: 'Ada');
  }
}
</code></pre>
<p><code>LoginScreen</code> now takes an <code>AuthRepository</code>, not an <code>AuthService</code>:</p>
<pre><code class="language-dart">class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key, required this.authRepository});
  final AuthRepository authRepository;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async =&gt; authRepository.login(email, password),
      child: const Text('Log in'),
    );
  }
}
</code></pre>
<p>And the test that this unlocks is direct, fast, and requires no network:</p>
<pre><code class="language-dart">testWidgets('logging in shows the dashboard', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: LoginScreen(authRepository: FakeAuthRepository()),
  ));
  await tester.tap(find.text('Log in'));
  await tester.pumpAndSettle();
  expect(find.byType(DashboardScreen), findsOneWidget);
});
</code></pre>
<p>Nothing about <code>LoginScreen</code> changed in terms of what it does. What changed is that it no longer knows or cares whether it's talking to a real API or a fake one — which is exactly the property that keeps a change in one feature from being able to break an unrelated one, because features now talk to each other through contracts (<code>AuthRepository</code>) instead of directly through concrete classes.</p>
<pre><code class="language-plaintext">Tightly coupled                     Decoupled

UI ──→ AuthService ──→ Network      UI ──→ AuthRepository (interface)
  └───→ DashboardService                        │
         └──→ AuthService (again)               ├──→ ApiAuthRepository ──→ Network
                                                 └──→ FakeAuthRepository ──→ (tests)

Changing AuthService risks          Changing the implementation behind
breaking Dashboard too.             the interface risks nothing above it.
</code></pre>
<h2>Testing and tooling aren't an afterthought at scale</h2>
<p>In a five-screen app, you can hold the whole thing in your head and manually verify a change didn't break anything. Past a certain size, that stops being true, and the things that replace "holding it in your head" are concrete, not aspirational: unit tests around repositories (exactly the kind the <code>FakeAuthRepository</code> example above makes trivial to write), integration tests around the flows that would be expensive to break in production, structured logging that tells you <em>which</em> feature a crash came from instead of just that one happened, and error tracking that surfaces problems before a user has to report them. None of these are optional "nice to have later" — they're what makes changing a fifty-screen app feel as safe as changing a five-screen one felt on day one.</p>
<h2>What this actually adds up to</h2>
<p>Flutter doesn't get slower or more fragile as your app grows — the framework's performance characteristics don't change. What changes is whether your structure was designed to absorb growth or just happened to survive the first few screens by accident. Feature folders keep related code together instead of scattered. Local-first state keeps screens from being invisibly coupled to state they don't use. Depending on interfaces instead of concrete classes keeps a change in one feature from being able to reach into another one. None of these are Flutter-specific ideas — they're general software architecture — but Flutter's low ceremony for "just make it work" makes it unusually easy to skip all three and not notice until the app is big enough that fixing it later is expensive.</p>
<p>Small apps are easy by default. Scalable apps are that way because someone decided, early, to structure them like they were going to grow — not because they got lucky.</p>
]]></content:encoded></item><item><title><![CDATA[Can AI Work Offline in Flutter? Here's What's Possible]]></title><description><![CDATA[Most Flutter apps with AI features assume a connection: type a message, call Gemini or an API, wait for a response. That assumption breaks the moment a user loses signal — and for a large share of the]]></description><link>https://gidudunicholas.dev/can-ai-work-offline-in-flutter-here-s-what-s-possible</link><guid isPermaLink="true">https://gidudunicholas.dev/can-ai-work-offline-in-flutter-here-s-what-s-possible</guid><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Tue, 07 Apr 2026 02:19:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5e6a520daf89662115c0ea14/5378f225-ffd7-42fe-806d-79262e2f2b67.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Flutter apps with AI features assume a connection: type a message, call Gemini or an API, wait for a response. That assumption breaks the moment a user loses signal — and for a large share of the world's users, especially outside major cities, unreliable connectivity isn't the exception you design around. It's the default you should be designing for.</p>
<p>So: can AI actually work offline in a Flutter app? Yes — but not in the single way most people picture when they hear the question. There isn't one answer, there are three levels, and knowing which one a feature actually needs is most of the engineering work.</p>
<h2>The assumption worth dropping first</h2>
<p>The instinctive picture of "AI" is a large model, a live API call, cloud compute — which quietly becomes the belief that AI requires an internet connection by definition. That was closer to true a few years ago. It isn't anymore. Lightweight on-device models, aggressive caching, and plain rule-based logic can each deliver something a user would call "smart" without a live connection, and a well-designed app usually needs all three, not just one.</p>
<h2>Level 1: Fully offline, on-device models</h2>
<p>The closest thing to true offline AI is running a model directly on the device instead of calling an API. Google's Gemma family is built specifically for this — small enough to run on a phone's CPU or NPU, capable of summarization, question-answering, and structured text generation, without a server in the loop.</p>
<p>Flutter itself doesn't run these models; it's the UI and orchestration layer sitting on top of a native inference engine. The most direct path today is the <code>flutter_gemma</code> package, which wraps Google's on-device runtime and handles the model-loading and inference calls for you:</p>
<pre><code class="language-yaml">dependencies:
  flutter_gemma: ^0.8.0
</code></pre>
<pre><code class="language-dart">final gemma = FlutterGemmaPlugin.instance;

Future&lt;void&gt; initModel() async {
  // Model file is downloaded once and stored locally — no network needed after this.
  await gemma.modelManager.installModelFromAsset('gemma-2b-it.bin');
}

Future&lt;String&gt; summarizeOffline(String text) async {
  final model = await gemma.createModel(modelType: ModelType.gemmaIt);
  final session = await model.createSession();
  await session.addQueryChunk(Message.text(text: 'Summarize in one sentence: $text'));
  final response = await session.getResponse();
  await session.close();
  return response;
}
</code></pre>
<p>Everything after <code>installModelFromAsset</code> runs entirely on-device — no fallback to a server, no latency spike from a bad connection, no data leaving the phone. That last property matters as much as the offline capability itself for anything privacy-sensitive: a journaling app, a health-tracking feature, anything where sending user text to a cloud API is a harder sell than running it locally.</p>
<p>The honest limitations: a 2B-parameter on-device model is meaningfully less capable than a cloud model at complex reasoning, inference is slower on older or lower-end hardware, and the model file itself adds real weight to your app's install size. Gemma-class models don't replace Gemini — they make a <em>specific slice</em> of AI features possible without one.</p>
<h2>Level 2: Hybrid — the approach most production apps actually need</h2>
<p>Most real systems shouldn't choose between offline and online; they should use both, switching based on what's actually available right now.</p>
<pre><code class="language-dart">Future&lt;String&gt; getAiResponse(String prompt) async {
  final connectivity = await Connectivity().checkConnectivity();
  final isOnline = connectivity != ConnectivityResult.none;

  if (isOnline) {
    try {
      return await cloudModel.generate(prompt); // Gemini — higher quality
    } catch (_) {
      // Network reported available but the call still failed — degrade, don't crash.
      return await localModel.summarizeOffline(prompt);
    }
  }
  return await localModel.summarizeOffline(prompt); // Gemma — always available
}
</code></pre>
<p>The <code>try/catch</code> inside the "online" branch matters as much as the connectivity check itself: <code>Connectivity().checkConnectivity()</code> tells you whether a network interface is up, not whether the actual API call will succeed. A user on a weak or captive-portal connection reads as "online" and then times out anyway — treating connectivity as a hint rather than a guarantee is what keeps that case from becoming a crash instead of a graceful fallback.</p>
<p>This pattern is the right fit for exactly the features you'd expect: a study assistant that gives richer explanations online and simpler ones offline, a finance app that gets sharper insights from a cloud model but never goes silent without one, anything where "slightly less capable" beats "completely unavailable."</p>
<h2>Level 3: Smart without a model at all</h2>
<p>The most underused approach, and often the highest-leverage one: many features that feel like AI don't need a model running at all, online or off.</p>
<p>If a user asks the same handful of questions repeatedly, cache the previous response and serve it instantly instead of re-generating it. If a request maps cleanly onto a small set of known intents ("show my progress," "what's next"), route it to a predefined action with <code>if</code>/<code>switch</code> logic instead of paying model latency for something deterministic. If you can precompute a recommendation from data you already have — yesterday's most-skipped lesson, a study streak — do that ahead of time and just display it.</p>
<pre><code class="language-dart">final _responseCache = &lt;String, String&gt;{};

Future&lt;String&gt; getCachedOrGenerate(String prompt, Future&lt;String&gt; Function() generate) async {
  if (_responseCache.containsKey(prompt)) {
    return _responseCache[prompt]!; // instant, no model call, works offline
  }
  final response = await generate();
  _responseCache[prompt] = response;
  return response;
}
</code></pre>
<p>Store the cache in <code>Hive</code> or <code>Isar</code> rather than an in-memory map if it needs to survive app restarts. None of this is AI in the model sense — but the user experiencing it can't tell the difference between "the model figured this out" and "the app remembered the answer from last time," and the second one costs nothing and never fails offline.</p>
<h2>Designing for offline from the start</h2>
<p>Retrofitting offline support onto a cloud-only feature is much harder than building the seam in from day one. A few decisions make the difference:</p>
<p><strong>Keep the cloud and local paths as genuinely separate layers</strong>, not one function with a network check bolted in — the hybrid example above works because <code>cloudModel</code> and <code>localModel</code> share a return type and neither knows the other exists.</p>
<p><strong>Cache aggressively and specifically</strong> — not just final responses, but the intermediate data (embeddings, parsed structures) that's expensive to regenerate, so a reconnect doesn't mean redoing work you already paid for.</p>
<p><strong>Queue rather than fail</strong> for anything that genuinely requires a network — a message send, a sync, a purchase. Store the pending action locally and retry when connectivity returns, instead of showing an error and discarding the user's input:</p>
<pre><code class="language-dart">final _pendingActions = &lt;Map&lt;String, dynamic&gt;&gt;[];

Future&lt;void&gt; submitAction(Map&lt;String, dynamic&gt; action) async {
  if (await isOnline()) {
    await api.send(action);
  } else {
    _pendingActions.add(action); // persist this to Hive/Isar in a real app
  }
}

// Call this when connectivity is restored (e.g. via a Connectivity listener).
Future&lt;void&gt; flushPendingActions() async {
  final toRetry = List&lt;Map&lt;String, dynamic&gt;&gt;.from(_pendingActions);
  _pendingActions.clear();
  for (final action in toRetry) {
    await submitAction(action); // re-queues automatically if still offline
  }
}
</code></pre>
<p><strong>Design for latency even when there's no network round-trip</strong> — on-device inference isn't instant either, so a loading state and progressive feedback still matter at Level 1, not just Level 2.</p>
<h2>The constraints that don't go away</h2>
<p>None of this makes offline AI free. Device hardware varies enormously — a five-year-old Android phone and a current iPhone will run the same on-device model at very different speeds, and you have to test on the low end, not just your own device. Smaller models genuinely reason less well and hold less context than their cloud counterparts, which limits what Level 1 alone can responsibly promise a user. iOS and Android differ in how they expose native inference, which shows up as platform-specific bugs you won't hit with a pure API-based feature. And debugging is harder across the board — a failure buried inside on-device inference gives you far less observability than a failed HTTP request with a status code and a response body.</p>
<h2>What this actually comes down to</h2>
<p>Offline isn't an edge case to handle after the "real" feature ships — for a meaningful share of users, it's the default condition the feature has to work under from day one. Pure cloud AI and pure offline AI both fail this test for different reasons: one goes silent without a connection, the other can't match a cloud model's reasoning. Hybrid systems, backed by aggressive caching and a fallback to simple rule-based logic when no model is warranted at all, are what actually hold up. The bar a user applies isn't "is this AI" — it's whether the app kept working when their connection didn't.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://pub.dev/packages/flutter_gemma">flutter_gemma package on pub.dev</a></p>
</li>
<li><p><a href="https://ai.google.dev/gemma">Google's Gemma model family</a></p>
</li>
<li><p><a href="https://pub.dev/packages/connectivity_plus">connectivity_plus package on pub.dev</a></p>
</li>
<li><p><a href="https://pub.dev/packages/hive">Hive — lightweight local database for Flutter</a></p>
</li>
<li><p><a href="https://pub.dev/packages/isar">Isar — local database for Flutter</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Building Agentic Flutter Apps with Gemini]]></title><description><![CDATA[AI has moved past static chatbots. The current wave of apps are agentic — they don't just answer a question in text, they reason about what the user is trying to do, decide what should happen next, an]]></description><link>https://gidudunicholas.dev/building-agentic-flutter-apps-with-gemini</link><guid isPermaLink="true">https://gidudunicholas.dev/building-agentic-flutter-apps-with-gemini</guid><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Mon, 18 Aug 2025 07:54:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755503642572/18d1b373-a42d-42b6-b66c-c7c0c6e3f650.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>AI has moved past static chatbots. The current wave of apps are <em>agentic</em> — they don't just answer a question in text, they reason about what the user is trying to do, decide what should happen next, and act on it. Instead of navigating to a pre-built screen, the user describes an outcome and the app figures out the UI and the actions required to get there.</p>
<p>This article walks through what that actually looks like in a Flutter app wired to Gemini — including the two things most walkthroughs on this topic skip: what happens when the model's output can't be trusted, and what you have to check before letting that output touch anything in the real world.</p>
<h2>What makes an app "agentic," concretely</h2>
<p>A traditional AI feature and an agentic one differ in what happens after the model responds.</p>
<p>In a traditional AI app, the user asks a question and the model answers in text. The conversation ends there — a human still has to read the answer, decide what it means, and go do something about it themselves.</p>
<p>In an agentic app, the model's output can also be a decision: change the UI to show a chart because the user asked for a trend, or emit an instruction to call a backend and fetch hotel prices. The user's request and the app's action are connected without a human manually bridging the gap in between. It's the difference between an assistant that tells you what to do and one that does the first step for you.</p>
<h2>Why Flutter fits this pattern</h2>
<p>Three properties make Flutter a reasonable platform for this, not just a popular one: widgets can be built from data at runtime instead of only from hand-written screens, which is what lets a model's JSON output become an actual UI; the same agent logic can run against mobile, web, and desktop from one codebase; and the ecosystem already has solid libraries for the pieces around the model call — HTTP clients, state management, local storage — so the agent-specific code stays small.</p>
<h2>Example: an AI travel planner</h2>
<p>Concretely: a user types "plan me a 3-day trip to Nairobi under $500." Gemini returns structured JSON describing an itinerary. Flutter renders that JSON as a scrollable list of day cards, each with an action button.</p>
<pre><code class="language-plaintext">┌────────────────┐        ┌──────────────────┐        ┌────────────────────┐
│   Flutter App   │──────▶│  Gemini (reason)  │──────▶│  Backend / APIs     │
│  UI + actions   │◀──────│  + structure JSON │◀──────│  flights, hotels    │
└────────────────┘        └──────────────────┘        └────────────────────┘
</code></pre>
<p>Flutter owns rendering and triggering actions. Gemini owns turning a loosely-worded request into a structured plan. The backend owns the real-world data neither of the other two can know on its own. Keeping those three responsibilities separate is what makes each of them replaceable later — you can swap Gemini for a different model, or swap a REST backend for GraphQL, without touching the other two.</p>
<h2>Calling Gemini from Flutter</h2>
<pre><code class="language-yaml">dependencies:
  google_generative_ai: ^0.4.6
  flutter_dotenv: ^5.1.0
</code></pre>
<p>Model names in this space go stale fast — <code>gemini-pro</code> and <code>gemini-1.5-pro</code> are both retired as of writing. At the time of publishing, <code>gemini-2.5-flash-lite</code> is Google's current low-latency default; check the <a href="https://ai.google.dev/gemini-api/docs/models">Gemini API model list</a> before you ship, since these get replaced roughly every few months.</p>
<pre><code class="language-dart">import 'package:google_generative_ai/google_generative_ai.dart';

final model = GenerativeModel(
  model: 'gemini-2.5-flash-lite',
  apiKey: dotenv.env['GEMINI_API_KEY']!,
);

Future&lt;String?&gt; askGemini(String prompt) async {
  try {
    final response = await model.generateContent([Content.text(prompt)]);
    return response.text;
  } catch (e) {
    // Network failure, quota limit, or safety-filter rejection all land here.
    debugPrint('Gemini call failed: $e');
    return null; // caller decides how to degrade — see the fallback UI below
  }
}
</code></pre>
<p>The <code>try/catch</code> here isn't decoration. Model calls fail for reasons that have nothing to do with your code — rate limits, transient network errors, or the model's own safety filters declining to answer — and a widget that assumes <code>response.text</code> always exists will crash on the first bad day the API has, not the tenth.</p>
<h2>Turning a response into a widget, without trusting it blindly</h2>
<p>If Gemini returns something like:</p>
<pre><code class="language-json">{
  "type": "itinerary",
  "days": [
    { "day": 1, "activity": "Visit Nairobi National Park" },
    { "day": 2, "activity": "Safari at Maasai Mara" }
  ]
}
</code></pre>
<p>the naive version parses it and renders directly. The problem is the same one every LLM-JSON pipeline runs into: the model occasionally wraps the JSON in a markdown code fence, drops a required field, or returns a type you didn't ask for. Validate the shape before you render it, and fall back to plain text when it doesn't hold up — the same rule that applies to any generative UI, not just agentic actions:</p>
<pre><code class="language-dart">class ItineraryDay {
  ItineraryDay({required this.day, required this.activity});
  final int day;
  final String activity;
}

List&lt;ItineraryDay&gt;? parseItinerary(String rawResponse) {
  try {
    final cleaned = rawResponse.replaceAll('```json', '').replaceAll('```', '').trim();
    final json = jsonDecode(cleaned) as Map&lt;String, dynamic&gt;;
    if (json['type'] != 'itinerary' || json['days'] is! List) return null;

    final days = (json['days'] as List)
        .whereType&lt;Map&lt;String, dynamic&gt;&gt;()
        .where((d) =&gt; d['day'] is int &amp;&amp; d['activity'] is String)
        .map((d) =&gt; ItineraryDay(day: d['day'], activity: d['activity']))
        .toList();

    return days.isEmpty ? null : days;
  } catch (_) {
    return null; // malformed JSON — caller falls back to text
  }
}

Widget buildItinerary(String rawResponse) {
  final days = parseItinerary(rawResponse);
  if (days == null) {
    return Text(rawResponse); // never crash the UI over a parsing failure
  }
  return ListView.builder(
    itemCount: days.length,
    itemBuilder: (context, i) =&gt; Card(
      child: ListTile(
        title: Text('Day ${days[i].day}'),
        subtitle: Text(days[i].activity),
      ),
    ),
  );
}
</code></pre>
<h2>Adding agency: validate the action before you execute it</h2>
<p>The higher-stakes version of the same JSON pattern is an action instruction rather than display data:</p>
<pre><code class="language-json">{ "action": "fetch_hotels", "location": "Nairobi", "budget": 200 }
</code></pre>
<p>It's tempting to switch on <code>json['action']</code> and call the matching function directly. Don't — this is the one place in an agentic pipeline where a parsing mistake stops being a cosmetic bug and becomes a real one, because you're one step away from letting model output decide which API calls happen and with what parameters. Two checks make the difference between "flexible" and "unsafe": the action has to be one your app explicitly knows about, and its parameters have to pass the same validation you'd apply to user input, because that's functionally what they are.</p>
<pre><code class="language-dart">enum AgentAction { fetchHotels, fetchFlights }

const _allowedActions = {'fetch_hotels': AgentAction.fetchHotels, 'fetch_flights': AgentAction.fetchFlights};
const _maxBudget = 5000;
final _allowedLocations = {'Nairobi', 'Kampala', 'Mombasa'}; // or validate against a real lookup

Future&lt;void&gt; handleAgentAction(Map&lt;String, dynamic&gt; json) async {
  final action = _allowedActions[json['action']];
  if (action == null) {
    debugPrint('Rejected unknown action: ${json['action']}');
    return; // unrecognized actions are dropped, never executed speculatively
  }

  final location = json['location'];
  final budget = json['budget'];
  if (location is! String || !_allowedLocations.contains(location)) return;
  if (budget is! int || budget &lt;= 0 || budget &gt; _maxBudget) return;

  switch (action) {
    case AgentAction.fetchHotels:
      await hotelRepository.search(location: location, maxPrice: budget);
    case AgentAction.fetchFlights:
      await flightRepository.search(destination: location);
  }
}
</code></pre>
<p>Notice what this buys you: the model can suggest any action name, any location string, any number it wants, and none of it reaches <code>hotelRepository</code> unless it matches an allowlist your code controls, not the model's output. If Gemini hallucinates an action called <code>delete_account</code> or a budget of <code>-1</code>, this function silently drops it instead of attempting it. That's the actual meaning of "don't let AI directly execute arbitrary actions" — a rule that only matters once you can see the validation it implies.</p>
<h2>The other three things that bite you in production</h2>
<p><strong>Prompt engineering</strong> is what keeps you out of the validation code above as often as possible — a prompt that specifies the exact schema and gives one or two examples of correct output produces far fewer rejected actions than an open-ended one.</p>
<p><strong>Latency</strong> means every Gemini call needs a loading state; a travel-planning request can take a few seconds, and a UI that just sits frozen reads as broken even when it isn't.</p>
<p><strong>Cost</strong> compounds with usage in a way flat per-screen features don't — cache itinerary results for identical prompts, and consider a cheaper/faster model tier for lower-stakes calls (classification, simple lookups) while reserving a stronger model for the reasoning-heavy ones.</p>
<h2>Where this is heading</h2>
<p>The trajectory is toward apps that adapt their own structure to what a user is trying to do — a dashboard that rearranges itself around a stated goal, an agent that sits inside an existing workflow rather than being a separate chat screen bolted onto one. None of that changes the fundamentals above: whatever the model decides, your app still has to validate it before rendering it and validate it again before acting on it. The interesting engineering problem in agentic apps was never getting the model to decide things — it's building the layer that keeps a wrong decision from becoming a real one.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://ai.google.dev/gemini-api/docs/models">Gemini API model list and lifecycle</a></p>
</li>
<li><p><a href="https://pub.dev/packages/google_generative_ai">google_generative_ai package on pub.dev</a></p>
</li>
<li><p><a href="https://ai.google.dev/gemini-api/docs/prompting-strategies">Gemini API prompt design strategies</a></p>
</li>
<li><p><a href="https://firebase.google.com/docs/ai-logic">Firebase AI Logic for Flutter</a></p>
</li>
<li><p><a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">OWASP guidance on LLM output handling</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Enhance Your Flutter App with a Real Logger Instead of print()]]></title><description><![CDATA[When you're starting out in Flutter, it feels natural to sprinkle print() statements everywhere. It's quick, it's always available, and it gets the job done for a small project. As your app grows, tha]]></description><link>https://gidudunicholas.dev/enhance-your-flutter-app-with-a-real-logger-instead-of-print</link><guid isPermaLink="true">https://gidudunicholas.dev/enhance-your-flutter-app-with-a-real-logger-instead-of-print</guid><category><![CDATA[Flutter]]></category><category><![CDATA[ logger]]></category><category><![CDATA[print]]></category><category><![CDATA[Dart]]></category><category><![CDATA[tech ]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Mon, 18 Aug 2025 07:15:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755501764643/0d42a65d-9b0b-4406-8a6c-b1003b96d390.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When you're starting out in Flutter, it feels natural to sprinkle <code>print()</code> statements everywhere. It's quick, it's always available, and it gets the job done for a small project. As your app grows, that habit stops being harmless and starts being a liability — not because <code>print()</code> is broken, but because it was never built for what you're actually asking it to do.</p>
<h2>The problem with print()</h2>
<p>Four issues compound as an app grows past the size where you can just read the console yourself.</p>
<p><strong>No log levels.</strong> Every <code>print()</code> message looks identical. An error, a warning, and a stray debug note all land in the console with the same weight, which makes filtering out noise during a real investigation harder than it needs to be.</p>
<p><strong>No persistence.</strong> Once the app session ends, every <code>print()</code> statement is gone. If a user reports a crash three screens deep into a flow you can't reproduce, you have nothing — there's no way to retrieve what actually happened on their device.</p>
<p><strong>Real performance cost at volume.</strong> A handful of <code>print()</code> calls cost nothing. Hundreds of them in a hot path — a list rebuilding on scroll, a network layer logging every request — add measurable overhead, because <code>print()</code> was designed for occasional debugging output, not structured, high-frequency logging.</p>
<p><strong>No metadata.</strong> <code>print()</code> captures none of a timestamp, the file it came from, or the line number. Staring at a stack of identical-looking debug lines while trying to reconstruct the order events happened in is a specific, avoidable kind of pain.</p>
<h2>The case for a real logger</h2>
<p>A proper logging package — <code>logger</code> is a common, solid choice — solves all four problems directly.</p>
<p><strong>Log levels give you filtering for free.</strong></p>
<pre><code class="language-dart">final logger = Logger();
logger.i('App started');
logger.w('Low memory warning');
logger.e('API request failed');
</code></pre>
<p><strong>Logs can persist and travel.</strong> Most logging libraries support writing to a file or forwarding to a remote service, so you can gather real diagnostic data from users who hit bugs you can't reproduce locally, instead of asking them to describe what happened from memory.</p>
<p><strong>Output is structured and readable</strong> — timestamps, colored levels, pretty-printed JSON, and stack traces attached automatically, instead of a wall of undifferentiated text.</p>
<p><strong>Production behavior is configurable</strong>, which is the feature that actually matters once you ship: you decide what gets logged in debug builds versus what (if anything) survives into a release build.</p>
<h2>A complete example</h2>
<pre><code class="language-dart">import 'package:logger/logger.dart';

var logger = Logger();

void main() {
  logger.d('This is a debug message');
  logger.i('This is an info message');
  logger.w('This is a warning');
  logger.e('This is an error with stacktrace', error: Exception('Oops'), stackTrace: StackTrace.current);
}
</code></pre>
<p>Run this and you get colorized, leveled output in place of raw <code>print()</code> text — visually distinct enough that scanning a long session for the one <code>.e()</code> call among a hundred <code>.i()</code> calls is immediate rather than a search.</p>
<h2>Gating log level by build mode</h2>
<p>The claim that a logger lets you "log everything in debug, only errors in production" is only useful if you can see how — and the mechanism is Flutter's own <code>kReleaseMode</code> constant, checked once at logger construction:</p>
<pre><code class="language-dart">import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

final logger = Logger(
  level: kReleaseMode ? Level.warning : Level.debug,
  printer: kReleaseMode ? SimplePrinter() : PrettyPrinter(),
);
</code></pre>
<p>In a debug build, every call from <code>.d()</code> up through <code>.e()</code> prints with full formatting. In a release build, <code>.d()</code> and <code>.i()</code> calls are silently dropped — they still exist in your code as documentation of what's happening at each step, but they cost nothing and reveal nothing to a user with <code>adb logcat</code> open, while warnings and errors still surface where you actually need them: in whatever you've wired logging to report back to.</p>
<h2>Forwarding errors to a crash reporter</h2>
<p>Local, in-console logging solves debugging on your own device. It does nothing for the user who hits a bug you'll never see unless you connect the logger to a service that persists across sessions and devices. The <code>logger</code> package's <code>Output</code> interface exists exactly for this — it lets you intercept log events and forward them anywhere:</p>
<pre><code class="language-dart">class CrashlyticsOutput extends LogOutput {
  @override
  void output(OutputEvent event) {
    if (event.level.index &gt;= Level.warning.index) {
      final message = event.lines.join('\n');
      FirebaseCrashlytics.instance.log(message);
      if (event.level == Level.error) {
        FirebaseCrashlytics.instance.recordError(
          event.origin.error ?? message,
          event.origin.stackTrace,
          fatal: false,
        );
      }
    }
  }
}

final logger = Logger(
  level: kReleaseMode ? Level.warning : Level.debug,
  output: MultiOutput([ConsoleOutput(), CrashlyticsOutput()]),
);
</code></pre>
<p>Now a single <code>logger.e('Payment failed', error: e, stackTrace: st)</code> call does three things at once: prints locally while you're developing, records a breadcrumb in Crashlytics either way, and creates a non-fatal error report you'll see in the Crashlytics dashboard the next morning — without ever asking a user what they were doing when it happened. Sentry's Dart SDK offers an equivalent integration built on the <code>logging</code> package if that's your crash-reporting tool of choice instead.</p>
<h2>Best practices</h2>
<p>Reserve <code>.e()</code> for genuine failures — marking routine, expected conditions as errors trains you to ignore your own error log, which defeats the purpose of having levels at all. Never log passwords, tokens, or personally identifying user data; a crash report that leaks a session token is a worse outcome than the crash itself. Keep production log volume deliberately minimal, both for performance and because a wide-open firehose to a remote service has its own cost and its own privacy surface. And treat the crash-reporting pairing as the point, not an optional extra — a logger that never leaves the device only helps you, and the goal is closing the loop on bugs you'll never personally see.</p>
<h2>A brief word on alternatives</h2>
<p><code>logger</code> isn't the only reasonable option. Flutter's own <code>debugPrint</code> throttles output to avoid Android's log-line truncation and is a fine lightweight step up from raw <code>print()</code> if you don't need levels or persistence yet. The Dart team's own <code>logging</code> package is a leaner, more standard-library-adjacent choice, and it's what Sentry's Dart integration builds on directly. Choose based on what you're pairing it with — if you're already committed to Crashlytics or Sentry, let that answer the question rather than picking a logging package first and working backward.</p>
<h2>Final thoughts</h2>
<p><code>print()</code> feels harmless in a small project because the project is small enough that you're the only one who ever reads the output, and the session ends before it matters that nothing persisted. Neither of those conditions holds once real users are running your app on devices you'll never see. A real logger costs one dependency and a few minutes of setup, and it's what turns "a user said the app crashed" into an actual stack trace on your screen the next morning.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://pub.dev/packages/logger">logger package on pub.dev</a></p>
</li>
<li><p><a href="https://firebase.google.com/docs/crashlytics/get-started?platform=flutter">Firebase Crashlytics for Flutter</a></p>
</li>
<li><p><a href="https://docs.sentry.io/platforms/dart/integrations/logging/">Sentry Dart SDK — logging integration</a></p>
</li>
<li><p><a href="https://pub.dev/packages/logging">Dart <code>logging</code> package</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/testing/build-modes">Flutter <code>kReleaseMode</code> and build modes</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[A Friendlier Guide to Taming Flutter Jank with DevTools]]></title><description><![CDATA[Picture this: you open your Flutter app, swipe to the next screen, and the animation hiccups for a beat before catching up. It's the software equivalent of spilling coffee on a white shirt — not catas]]></description><link>https://gidudunicholas.dev/a-friendlier-guide-to-taming-flutter-jank-with-devtools</link><guid isPermaLink="true">https://gidudunicholas.dev/a-friendlier-guide-to-taming-flutter-jank-with-devtools</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[devtools]]></category><category><![CDATA[flutter devtools]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Sun, 01 Jun 2025 11:31:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748777402024/b0572b0c-a22b-42f4-833b-832dcb1c4cc9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Picture this: you open your Flutter app, swipe to the next screen, and the animation hiccups for a beat before catching up. It's the software equivalent of spilling coffee on a white shirt — not catastrophic, but you notice it immediately and so does everyone else. The good news is that Flutter DevTools is built specifically for finding out why that hiccup happened, and you don't need to be a performance specialist to use it. Let's walk through it like two colleagues at the same desk.</p>
<h2>1. Pick the right run mode before you start</h2>
<p>Where you run the app changes what you can actually see.</p>
<p><strong>Debug mode</strong> (<code>flutter run</code>) is where you write code, but it's also where every widget carries extra assertions and debugging hooks that real users never see — which means jank you find here can be an artifact of debug mode itself, not something your users will experience.</p>
<p><strong>Profile mode</strong> (<code>flutter run --profile</code>) strips those debug-only costs while keeping the tracing hooks DevTools needs. This is where you should do essentially all of your performance investigation — it's close enough to release-build speed to trust the numbers, while still being instrumented enough to explain them.</p>
<p><strong>Release mode</strong> (<code>flutter run --release</code>) is the real thing, with no tracing by default. You can still attach DevTools to a release build for a final sanity check, but you lose most of the diagnostic detail that makes the investigation useful.</p>
<p>For a quick, no-DevTools-required gut check while you're testing a flow, flip on the built-in performance overlay:</p>
<pre><code class="language-dart">MaterialApp(
  showPerformanceOverlay: true,
  home: const MyHomePage(),
)
</code></pre>
<p>This paints two graphs directly on the running app: the raster thread's time per frame on top, the UI thread's time per frame below. Green bars mean you're comfortably under budget; a bar that climbs into the red zone is a dropped frame, and you'll see which of the two threads caused it without opening a browser tab.</p>
<h2>2. Opening DevTools without breaking your flow</h2>
<p>Most IDEs — VS Code and Android Studio both — show an "Open DevTools" button the moment your app connects to a device or emulator, which is the easiest path. If you'd rather use the CLI:</p>
<pre><code class="language-bash">dart pub global activate devtools
dart pub global run devtools
</code></pre>
<p>It opens in your browser and attaches to the first running Flutter process it finds. If you have several processes running at once (a phone and a simulator, say), DevTools will ask you to pick.</p>
<h2>3. Reading the Performance page like a story</h2>
<p>Once attached, the <strong>Performance</strong> tab is where jank hunting actually happens. Three things on it matter most.</p>
<p>The <strong>frame chart</strong> shows a bar per rendered frame, colored by how long it took: green means you hit your frame budget (16.6ms for 60fps, less for 120Hz displays), red means you didn't. Scanning left to right, a cluster of red bars right after a specific interaction — a tap, a scroll start — is your first clue about where to look.</p>
<p>Clicking any bar splits it into <strong>UI time and raster time</strong>. A bar with a tall UI-thread segment usually means your <code>build()</code> methods are doing too much work — heavy computation, an over-eager <code>setState</code>, or business logic that shouldn't be running on every frame. A bar with a tall raster-thread segment usually points at something the GPU is struggling with — large images being decoded and painted, complex shader effects, or excessive layering.</p>
<p>The <strong>timeline view</strong> below the frame chart shows the UI and raster threads as parallel horizontal tracks over time, with individual events (<code>build</code>, <code>layout</code>, <code>paint</code>) as labeled blocks. A single long block sitting on the UI thread is the most direct evidence you'll get that something ran synchronously when it should have been async or deferred.</p>
<h2>4. A worked example: finding an actual dropped frame</h2>
<p>Here's a screen that reliably janks on scroll — a list of cards where each one computes something expensive directly inside <code>build()</code>:</p>
<pre><code class="language-dart">class ProductGrid extends StatelessWidget {
  const ProductGrid({super.key, required this.products});
  final List&lt;Product&gt; products;

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      itemCount: products.length,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
      itemBuilder: (context, i) {
        final discount = _computeBestDiscount(products[i]); // expensive, runs every rebuild
        return ProductCard(product: products[i], discount: discount);
      },
    );
  }

  double _computeBestDiscount(Product p) {
    // Simulates a non-trivial calculation — sorting/filtering a list of offers, say.
    var best = 0.0;
    for (final offer in p.offers) {
      best = offer.percentOff &gt; best ? offer.percentOff : best;
    }
    return best;
  }
}
</code></pre>
<p>Running this in profile mode and scrolling the grid: the frame chart shows a run of red bars exactly while scrolling starts, and clicking one shows the UI thread segment dominating the frame — not raster. That's the signal that this is a build-cost problem, not a rendering-cost one. The timeline view confirms it: a wide <code>build</code> block on the UI thread, one per visible card, every single frame.</p>
<p>The fix follows directly from the diagnosis — move the calculation out of <code>build()</code> so it happens once per product instead of once per frame:</p>
<pre><code class="language-dart">class ProductGrid extends StatelessWidget {
  ProductGrid({super.key, required this.products})
      : discounts = {for (final p in products) p.id: _computeBestDiscount(p)};

  final List&lt;Product&gt; products;
  final Map&lt;String, double&gt; discounts; // computed once, not per rebuild

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      itemCount: products.length,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
      itemBuilder: (context, i) =&gt; ProductCard(
        product: products[i],
        discount: discounts[products[i].id]!,
      ),
    );
  }

  static double _computeBestDiscount(Product p) {
    var best = 0.0;
    for (final offer in p.offers) {
      best = offer.percentOff &gt; best ? offer.percentOff : best;
    }
    return best;
  }
}
</code></pre>
<p>Re-recording the same scroll in DevTools now shows a flat run of green bars. Nothing about what the user sees changed — the fix was entirely about <em>when</em> the work happens, which is exactly the kind of thing the frame chart is built to expose and prose alone can't demonstrate.</p>
<h2>5. Finding rebuild culprits directly</h2>
<p>For the more general case — you suspect something is rebuilding too often but don't know what — the Widget Inspector's rebuild tracking is the more direct tool than reasoning from the timeline. In the Inspector tab, enable <strong>Track widget rebuilds</strong>, then interact with your app. Widgets light up each time they rebuild, and a rapidly-flashing widget that has nothing to do with the interaction you just performed is exactly the pattern described in the rebuilds problem generally: state declared too high in the tree, dragging unrelated widgets down with it every time it changes.</p>
<h2>6. Shader compilation jank on first launch</h2>
<p>If raster time spikes specifically the first time a particular animation or transition runs — and stays fast on every subsequent run — that's very likely shader compilation jank, not a rendering bug in your code. Flutter compiles shaders lazily the first time they're used, and that compilation cost shows up as a stutter your users will hit on their very first session, which is the worst possible time for it.</p>
<p>The fix is to warm shaders up ahead of time by capturing an SkSL bundle from a profile-mode run that exercises your app's visual surface, then shipping that bundle with your release build so the compilation happens before the user ever sees the affected screen. The exact commands and current recommended flow are documented on Flutter's own wiki, since the tooling here has changed across versions — worth checking that page directly rather than trusting a snippet from a blog post that might be a year stale by the time you read it.</p>
<h2>7. CPU and memory, one click away</h2>
<p>The same DevTools session gives you two more views without leaving your browser tab. The <strong>CPU Profiler</strong> samples your app's execution and shows where time is actually being spent at the function level — useful when the Performance page has told you <em>that</em> something is slow but not <em>what</em>. A <strong>memory snapshot</strong>, taken before and after navigating to a screen and back, will show you whether objects from that screen got garbage collected or are still hanging around — the classic sign of a leaked controller or subscription that was never disposed.</p>
<h2>A one-sprint performance routine</h2>
<p>Spreading this across a week, rather than doing it once before a release, is what actually keeps an app fast over time:</p>
<ul>
<li><p><strong>Day 1:</strong> Record a profile-mode baseline on your slowest supported device, not your development phone.</p>
</li>
<li><p><strong>Day 2:</strong> Fix the single worst offender the frame chart points to.</p>
</li>
<li><p><strong>Day 3:</strong> Warm up shaders on a release build if first-launch jank showed up.</p>
</li>
<li><p><strong>Day 4:</strong> Run one exploratory CPU/memory session, without a specific bug in mind — you're looking for anything surprising.</p>
</li>
<li><p><strong>Day 5:</strong> Re-record the same interaction from Day 1 and confirm the frame chart actually improved, not just that the fix felt right.</p>
</li>
</ul>
<p>A little every sprint beats the week-before-release scramble, mostly because performance regressions are much cheaper to find one commit after they were introduced than three months later.</p>
<h2>Bottom line</h2>
<p>DevTools isn't a tool you reach for only during a crisis. The frame chart, the rebuild tracker, and the CPU/memory views together turn "the app feels laggy" from a vague impression into a specific, fixable claim — a named widget, a named thread, a named function. Fire it up, follow the colored bars, and the stutters get a lot easier to find than they are to guess at.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.flutter.dev/tools/devtools/performance">Flutter DevTools: Performance page</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/tools/devtools/inspector">Flutter DevTools: Inspector and widget rebuild tracking</a></p>
</li>
<li><p><a href="https://github.com/flutter/flutter/wiki/Reduce-shader-compilation-jank-using-SkSL-warm-up">Reducing shader compilation jank with SkSL warm-up</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/perf/best-practices">Flutter performance best practices</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/tools/devtools/cpu-profiler">Flutter DevTools: CPU Profiler</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Essential Tips for Effective Error Handling in Flutter Apps]]></title><description><![CDATA[Picture this: you've just downloaded a new app, tapped a button, and — boom — white screen, cryptic message, or worse, a full crash. Odds are you'll uninstall it before you even remember its name. As ]]></description><link>https://gidudunicholas.dev/essential-tips-for-effective-error-handling-in-flutter-apps</link><guid isPermaLink="true">https://gidudunicholas.dev/essential-tips-for-effective-error-handling-in-flutter-apps</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Sat, 31 May 2025 21:10:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748725786742/7a670d5e-5e2b-4987-bb33-a36a9a685907.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Picture this: you've just downloaded a new app, tapped a button, and — boom — white screen, cryptic message, or worse, a full crash. Odds are you'll uninstall it before you even remember its name. As Flutter developers we can't promise zero bugs, but we can promise our users won't feel abandoned when things go sideways.</p>
<p>Below is a people-first walkthrough of error handling in current Flutter (Dart 3.x). Less jargon, more common sense — and every code example below is one you can actually run as written.</p>
<h2>1. First, let's name the gremlins</h2>
<table>
<thead>
<tr>
<th>Where it breaks</th>
<th>Real-life example</th>
<th>How you catch it</th>
</tr>
</thead>
<tbody><tr>
<td>Plain old Dart code</td>
<td>Dividing by zero, parsing bad JSON, forgetting that <code>null</code> exists</td>
<td>A simple <code>try {…} catch</code></td>
</tr>
<tr>
<td>Flutter framework</td>
<td>A widget tries to paint with infinite width</td>
<td><code>FlutterError.onError</code></td>
</tr>
<tr>
<td>Background stuff</td>
<td>An isolate tries to read a file that isn't there, or an async callback throws outside any <code>try</code> block</td>
<td><code>runZonedGuarded</code> and <code>PlatformDispatcher.instance.onError</code></td>
</tr>
</tbody></table>
<p>Think of these as different rooms in a house. If you only lock the front door, someone can still climb in through the basement window. Lock every entrance, and the strategy below builds outward from the smallest one to the largest.</p>
<h2>2. try / catch / finally — the everyday seatbelt</h2>
<pre><code class="language-dart">try {
  final user = await api.getUser(id);
} on TimeoutException {
  throw const NetworkFailure(message: 'The server is taking too long.');
} catch (e, st) {
  debugPrint('Unexpected error: $e\n$st');
  rethrow; // bubble it up so the global handler in section 3 sees it too
} finally {
  loadingSpinner.hide();
}
</code></pre>
<p>Two habits make this pattern actually useful instead of just present: put the specific catches first (<code>on TimeoutException</code>) and the generic <code>catch</code> last, since Dart checks them in order and a generic catch placed first would swallow the specific one silently. And keep the stack trace (<code>st</code>) even when you're not using it immediately — it's the difference between a five-minute fix and an hour of guessing when this shows up in a crash report next week.</p>
<h2>3. Your safety net: one block that catches everything else</h2>
<pre><code class="language-dart">Future&lt;void&gt; main() async {
  WidgetsFlutterBinding.ensureInitialized();

  FlutterError.onError = (details) {
    FlutterError.presentError(details); // still shows in debug console
    reportError(details.exception, details.stack);
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    reportError(error, stack);
    return true; // tells the platform this error was handled
  };

  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stack) =&gt; reportError(error, stack));
}
</code></pre>
<p>Three separate hooks are doing three separate jobs here: <code>FlutterError.onError</code> catches framework-level errors (bad widget builds, layout failures), <code>PlatformDispatcher.instance.onError</code> catches errors from async callbacks and platform channels that land outside the zone Flutter runs in, and <code>runZonedGuarded</code> catches anything that escapes a <code>try/catch</code> inside your own <code>async</code> code. Any one of these alone leaves a gap; together, nothing that reaches your app should ever reach the user without at least being logged first.</p>
<h2>4. Show, don't scare</h2>
<p>Flutter's default error screen — the red screen in debug, a gray blank one in release — is honest but not kind. Replace it:</p>
<pre><code class="language-dart">ErrorWidget.builder = (details) =&gt; Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      const Icon(Icons.error_outline, size: 64),
      const SizedBox(height: 12),
      const Text('Oops! Something went wrong.'),
      const SizedBox(height: 12),
      TextButton(
        onPressed: () =&gt; Restart.restartApp(), // e.g. via the `restart` package
        child: const Text('Try again'),
      ),
    ],
  ),
);
</code></pre>
<p>For problems the user can plausibly recover from without a full restart — a dropped connection mid-request — a snackbar or banner with a retry action ("Lost connection — tap to retry") is friendlier than replacing the whole screen. And where you can, cache the last known-good data locally so "offline" shows <em>something</em> stale rather than nothing at all — a slightly outdated list beats an empty one.</p>
<h2>5. Speak human, not stack trace</h2>
<p>Raw exceptions and bare strings don't give your UI anything meaningful to show. A sealed class with named subtypes does — and this is the version that actually compiles and switches correctly, unlike named constructors on a single class, which all share one <code>runtimeType</code> and can't be distinguished this way:</p>
<pre><code class="language-dart">sealed class AuthFailure implements Exception {
  const AuthFailure();
}

class InvalidLogin extends AuthFailure {
  const InvalidLogin();
}

class ExpiredToken extends AuthFailure {
  const ExpiredToken();
}

class UnknownAuthFailure extends AuthFailure {
  const UnknownAuthFailure();
}

extension AuthFailureReason on AuthFailure {
  String get reason =&gt; switch (this) {
    InvalidLogin() =&gt; 'Email or password is incorrect.',
    ExpiredToken() =&gt; 'Session expired. Please log in again.',
    UnknownAuthFailure() =&gt; 'Something went wrong. Try again.',
  };
}
</code></pre>
<p>Because <code>AuthFailure</code> is <code>sealed</code>, Dart's compiler checks that the <code>switch</code> covers every subtype — if you add a new failure case later and forget to handle it in <code>reason</code>, this won't compile until you do, which is a real safety net a plain-string approach can't give you. The UI now just displays <code>failure.reason</code> and never needs to know or care what specifically went wrong underneath.</p>
<h2>6. Results over roulette — and when to reach for one</h2>
<p>Some teams skip exceptions entirely for <em>expected</em> failures — a wrong password, a validation error — and use a <code>Result&lt;T, E&gt;</code> type instead, reserving thrown exceptions for genuinely unexpected conditions (a bug, a contract violation):</p>
<pre><code class="language-dart">final Result&lt;User, AuthFailure&gt; result = await repo.signIn(email, password);
switch (result) {
  case Ok(value: final user):
    showHome(user);
  case Err(error: final failure):
    showError(failure.reason); // the sealed class from section 5
}
</code></pre>
<p>The distinction that decides which tool to reach for: if a caller is <em>expected</em> to handle a failure as part of normal control flow — wrong password happens to real users constantly — model it as a <code>Result</code> and force every call site to handle both branches. If a failure represents something that should never happen if the code is correct — a null value where your own logic guaranteed one — let it throw and get caught by the global handler in section 3, because a <code>Result</code> type would just be papering over a bug with a designed-for-failure abstraction. The <code>AuthFailure</code> sealed class from section 5 fits naturally into either approach; it's the transport mechanism (throw vs. return) that changes, not the failure model itself.</p>
<h2>7. Don't keep secrets — log and report</h2>
<p>A <code>try/catch</code> that only prints to a local console is only useful for bugs you happen to be there to see. Firebase Crashlytics and Sentry both exist to catch what your global handlers report, from every user's device, whether or not you're watching:</p>
<pre><code class="language-dart">// Crashlytics
FirebaseCrashlytics.instance.recordError(
  error,
  stack,
  fatal: false, // this is what actually makes it "non-fatal" rather than a crash report
);

// Sentry
await Sentry.captureException(error, stackTrace: stack);
</code></pre>
<p>Wire either one into the <code>reportError</code> function from section 3 and every error your global handlers catch reaches the dashboard automatically. Add breadcrumbs before risky calls too — <code>log('Fetching profile for $id')</code> right before the request — so that when an error does land, you're not just looking at a stack trace, you're looking at the sequence of events that led to it. An error that happens in the forest with no one there to hear it doesn't stop happening — it just keeps happening to users who never file a report.</p>
<h2>8. Practice failing</h2>
<p>Three concrete ways to verify your error handling actually works, rather than trusting that it does:</p>
<p><strong>Unit tests</strong> that force a failure path and assert on the result — expect a <code>NetworkFailure</code> (or <code>Err(NetworkFailure())</code>) when a mocked client times out, not just when everything goes right.</p>
<p><strong>Widget tests</strong> that pump a widget with an injected failing dependency and confirm your fallback UI — the friendly error screen from section 4, not the default one — actually renders.</p>
<p><strong>A staging dry run</strong> that deliberately triggers a crash to confirm reporting works end-to-end: <code>FirebaseCrashlytics.instance.crash()</code> behind a debug-only button, checked into a build that never ships to production, just to see the report land in your dashboard before you need it for real.</p>
<h2>The short checklist</h2>
<ul>
<li><p>Wrap risky code in <code>try</code>/<code>catch</code>, specific cases before the generic one</p>
</li>
<li><p>Install all three global guards — <code>FlutterError.onError</code>, <code>PlatformDispatcher.instance.onError</code>, <code>runZonedGuarded</code></p>
</li>
<li><p>Replace the default error screen with something a user won't panic at</p>
</li>
<li><p>Model expected failures with a sealed class or <code>Result</code> type, not raw strings</p>
</li>
<li><p>Forward every caught error to Crashlytics or Sentry, with breadcrumbs</p>
</li>
<li><p>Write at least one test per category — unit, widget, and a real dry-run crash</p>
</li>
</ul>
<h2>Parting words</h2>
<p>Bugs are inevitable; rage-quits and one-star reviews aren't. Handle errors the way you'd comfort a nervous passenger on a turbulent flight: acknowledge the bump, explain the plan, and land smoothly. Do that consistently and your users stay buckled in for the rest of the journey.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.flutter.dev/testing/errors">Handling errors in Flutter</a></p>
</li>
<li><p><a href="https://dart.dev/language/class-modifiers#sealed">Dart: sealed classes and exhaustive switches</a></p>
</li>
<li><p><a href="https://firebase.google.com/docs/crashlytics/get-started?platform=flutter">Firebase Crashlytics for Flutter</a></p>
</li>
<li><p><a href="https://docs.sentry.io/platforms/flutter/">Sentry Flutter SDK</a></p>
</li>
<li><p><a href="https://api.dart.dev/stable/dart-async/runZonedGuarded.html">runZonedGuarded — dart:async documentation</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Flutter Flavors Explained: How to Implement and Why You Should]]></title><description><![CDATA[You've built your Flutter app, and everything is looking great. Then reality sets in: you need a development build to test against a staging API, a QA build for testers, and a production build for rea]]></description><link>https://gidudunicholas.dev/flutter-flavors-explained-how-to-implement-and-why-you-should</link><guid isPermaLink="true">https://gidudunicholas.dev/flutter-flavors-explained-how-to-implement-and-why-you-should</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Flutter flavors]]></category><category><![CDATA[Dart]]></category><category><![CDATA[technology]]></category><category><![CDATA[flavours]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Tue, 27 May 2025 09:07:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748336755899/77666eb6-1dcd-4db7-a87e-0670845aac72.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You've built your Flutter app, and everything is looking great. Then reality sets in: you need a development build to test against a staging API, a QA build for testers, and a production build for real users — and you don't want to hand-edit a config file and hope you remember to change it back before shipping.</p>
<p>That's what flavors solve: separate, buildable configurations of the same codebase, switched with a flag instead of a manual edit. The part most guides skip is that "flavor" actually means two connected things — a native build variant (which Android/iOS artifact gets compiled, with its own app ID, name, and icon) and a Dart-level configuration (which API URL, which feature flags) — and the two have to be wired together explicitly, or you end up with a dev-labeled app icon that's quietly still hitting your production API. This guide builds both halves and connects them.</p>
<h2>What flavors actually give you</h2>
<p>Same codebase, different builds: a dev build that talks to a test API and says "Dev" in its name and icon, a staging build your QA team installs, and a production build with the real API and the real logo — all three installable on the same device at once, because they have distinct application IDs.</p>
<h2>Step 1: Android — product flavors in Gradle</h2>
<p>This is the half most guides gesture at without showing. In <code>android/app/build.gradle</code> (or <code>build.gradle.kts</code> if you've migrated to Kotlin DSL), inside the <code>android</code> block:</p>
<pre><code class="language-groovy">android {
    // ...existing config...

    flavorDimensions "environment"

    productFlavors {
        dev {
            dimension "environment"
            applicationIdSuffix ".dev"
            resValue "string", "app_name", "MyApp Dev"
        }
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
            resValue "string", "app_name", "MyApp Staging"
        }
        prod {
            dimension "environment"
            resValue "string", "app_name", "MyApp"
        }
    }
}
</code></pre>
<p><code>applicationIdSuffix</code> is what makes <code>dev</code> and <code>staging</code> installable side by side with <code>prod</code> on the same device — they become genuinely different packages (<code>com.yourcompany.app.dev</code> instead of <code>com.yourcompany.app</code>), not just different-looking builds of the same one. The <code>resValue</code> lines generate a string resource per flavor, which is why <code>AndroidManifest.xml</code> should reference the app name indirectly:</p>
<pre><code class="language-xml">&lt;application android:label="@string/app_name" ...&gt;
</code></pre>
<p>instead of a hardcoded string — that one line is what lets three different flavor builds show three different names without three different manifests.</p>
<h2>Step 2: iOS — build configurations and schemes</h2>
<p>iOS doesn't have a Gradle-style flavor system; it uses build configurations and schemes, so the setup is more manual but follows the same idea.</p>
<p>In Xcode, open <code>Runner.xcworkspace</code>, select the <strong>Runner</strong> project, and duplicate each existing configuration (Debug, Release, Profile) once per flavor, giving you <code>Debug-dev</code>, <code>Release-dev</code>, <code>Debug-staging</code>, <code>Release-staging</code>, and so on. For each new configuration, create a matching <code>.xcconfig</code> file under <code>ios/Flutter/</code> — for example, <code>Flutter/Dev.xcconfig</code>:</p>
<pre><code class="language-plaintext">#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.app.dev
PRODUCT_NAME = MyApp Dev
</code></pre>
<p>and <code>Flutter/Prod.xcconfig</code>:</p>
<pre><code class="language-plaintext">#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.app
PRODUCT_NAME = MyApp
</code></pre>
<p>Assign each <code>.xcconfig</code> file to its matching build configuration under the project's <strong>Info</strong> tab. Then create a <strong>Scheme</strong> per flavor (Product → Scheme → Manage Schemes → duplicate <code>Runner</code> as <code>dev</code>, <code>staging</code>, <code>prod</code>), pointing each scheme's Run/Build/Archive actions at the matching configuration. This is what makes <code>flutter run --flavor dev</code> resolve to something concrete on the iOS side — without a scheme named <code>dev</code>, the flag has nothing to find.</p>
<h2>Step 3: entry points that actually receive the flavor</h2>
<p>This is the piece that connects native and Dart, and it's the part the naive version of this pattern (a <code>const</code> string passed as a plain function argument) doesn't do — that approach can select a different <code>main_*.dart</code> file, but it never confirms which <em>native</em> flavor was actually built, so Dart and the native layer can silently disagree.</p>
<pre><code class="language-dart">// lib/main_dev.dart
import 'flavor_config.dart';
import 'main.dart' as app;

void main() {
  FlavorConfig.initialize(Flavor.dev);
  app.main();
}
</code></pre>
<pre><code class="language-dart">// lib/main_prod.dart
import 'flavor_config.dart';
import 'main.dart' as app;

void main() {
  FlavorConfig.initialize(Flavor.prod);
  app.main();
}
</code></pre>
<pre><code class="language-dart">// lib/flavor_config.dart
enum Flavor { dev, staging, prod }

class FlavorConfig {
  FlavorConfig._(this.flavor, this.apiUrl, this.appName);

  final Flavor flavor;
  final String apiUrl;
  final String appName;

  static late FlavorConfig instance;

  static void initialize(Flavor flavor) {
    instance = switch (flavor) {
      Flavor.dev =&gt; FlavorConfig._(flavor, 'https://api.dev.myapp.com', 'MyApp Dev'),
      Flavor.staging =&gt; FlavorConfig._(flavor, 'https://api.staging.myapp.com', 'MyApp Staging'),
      Flavor.prod =&gt; FlavorConfig._(flavor, 'https://api.myapp.com', 'MyApp'),
    };
  }
}
</code></pre>
<pre><code class="language-dart">// lib/main.dart
import 'package:flutter/material.dart';
import 'flavor_config.dart';

void main() {
  runApp(MyApp(config: FlavorConfig.instance));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.config});
  final FlavorConfig config;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: config.appName,
      home: Scaffold(
        appBar: AppBar(title: Text(config.appName)),
        body: Center(child: Text('API: ${config.apiUrl}')),
      ),
    );
  }
}
</code></pre>
<p><code>FlavorConfig</code> now holds everything Dart-side code needs — API URL, display name, feature flags if you add them — set once at startup by whichever entry point ran.</p>
<h2>Step 4: run and build with both halves connected</h2>
<p>The flag that actually ties the Dart entry point to the native build variant is <code>--flavor</code>, used alongside <code>-t</code> to pick the entry point:</p>
<pre><code class="language-bash">flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor prod -t lib/main_prod.dart
</code></pre>
<p><code>-t</code> alone picks which Dart file runs <code>main()</code>; <code>--flavor</code> alone picks which native product flavor / scheme gets compiled. Omit <code>--flavor</code> and Flutter builds the default native variant regardless of which Dart entry point you specified — which is exactly the silent-mismatch failure mode this two-flag combination exists to prevent. The same pairing carries over to release builds:</p>
<pre><code class="language-bash">flutter build apk --flavor prod -t lib/main_prod.dart
flutter build ipa --flavor prod -t lib/main_prod.dart
</code></pre>
<h2>Best practices, from what actually bites teams</h2>
<p><strong>Name every flavor's app visibly different</strong> — "MyApp Dev," "MyApp Staging," distinct icons per flavor — so nobody on your team, especially QA, ever mistakes one running build for another. This matters more than it sounds like it should; "which build is this" is a surprisingly common support question once you have three of them installed on one device.</p>
<p><strong>Never hardcode secrets in</strong> <code>FlavorConfig</code> <strong>or anywhere else in source</strong> — API keys and signing secrets belong in <code>--dart-define</code> values injected at build time, or a <code>.env</code> file per flavor loaded via <code>flutter_dotenv</code>, not committed alongside the flavor definitions themselves.</p>
<p><strong>Automate the flag combinations in CI</strong> rather than trusting a human to remember <code>--flavor prod -t lib/main_prod.dart</code> correctly every release — a CI job with three named build steps (<code>build-dev</code>, <code>build-staging</code>, <code>build-prod</code>) removes the exact class of mistake this whole setup is designed around.</p>
<p><strong>Consider</strong> <code>flutter_flavorizr</code> if you're setting this up across several projects or from scratch — it automates the Gradle and Xcode scaffolding above from a single YAML config, at the cost of some flexibility if your setup needs to deviate from its defaults. Doing it manually once, as above, is worth understanding even if you later automate it, since debugging a flavorizr-generated project without knowing what it generated is much harder than debugging one you built by hand.</p>
<h2>Final thoughts</h2>
<p>Flavors save time and prevent a specific, expensive class of mistake — shipping a test build to real users, or a real build talking to a test API — but only when the native build variant and the Dart-level configuration are actually wired together, not just running side by side under the same name. Set it up once with both halves connected, and switching environments becomes a single flag instead of a source of quiet, hard-to-debug mismatches.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.flutter.dev/deployment/flavors">Flutter's official flavors documentation</a></p>
</li>
<li><p><a href="https://developer.android.com/build/build-variants">Android Gradle Plugin: configuring product flavors</a></p>
</li>
<li><p><a href="https://pub.dev/packages/flutter_flavorizr">flutter_flavorizr on pub.dev</a></p>
</li>
<li><p><a href="https://pub.dev/packages/flutter_dotenv">flutter_dotenv on pub.dev</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/deployment/flavors#step-4-run-flutter-app">Flutter: passing arguments to flutter run and build</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Enhance Your Mobile Apps: Optimize Multitasking with Isolates]]></title><description><![CDATA[Imagine your Flutter app as a busy restaurant kitchen. The head cook — the UI thread — needs every dish (your animations, taps, scrolls) plated and served on time. Then you ask that same cook to also ]]></description><link>https://gidudunicholas.dev/enhance-your-mobile-apps-optimize-multitasking-with-isolates</link><guid isPermaLink="true">https://gidudunicholas.dev/enhance-your-mobile-apps-optimize-multitasking-with-isolates</guid><category><![CDATA[dart-isolates]]></category><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Tue, 20 May 2025 19:15:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1747768443330/82b7097a-da07-40cb-91c9-df675b820aa5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Imagine your Flutter app as a busy restaurant kitchen. The head cook — the UI thread — needs every dish (your animations, taps, scrolls) plated and served on time. Then you ask that same cook to also prep dessert, scrub the ovens, and take phone orders. Things slow down, and your diners start tapping their watches.</p>
<p>Dart isolates are extra chefs, each with a fully equipped kitchen of their own. They take the heavy lifting — parsing huge JSON files, resizing images, running complex calculations — off the head cook entirely, without ever crowding the main kitchen.</p>
<p>Mobile users expect a buttery-smooth UI even while an app crunches data in the background. On Flutter, the tool that makes that possible is the isolate.</p>
<h2>1. Why multitasking matters on mobile</h2>
<p>Every Flutter app runs its UI on a single main isolate — the platform's "UI thread." Heavy work (JSON parsing, file I/O, encryption, ML inference) run directly on that isolate blocks it, which shows up to the user as jank or, in the worst case, an ANR (Android's "app not responding" dialog). Moving that work to a background isolate lets the UI keep rendering frames at 60–120fps while the heavy lifting happens somewhere else entirely.</p>
<h2>2. What exactly is an isolate?</h2>
<p>An isolate is a Dart execution context with its own event loop and its own memory heap. Unlike traditional OS threads, isolates share no mutable state — they communicate exclusively by passing messages (<code>SendPort</code> ↔ <code>ReceivePort</code>) containing simple values or transferable typed data like <code>Uint8List</code>. If you're coming from Java or Kotlin, the right mental model is the actor model, not synchronized blocks — there's no shared memory to protect with a lock, because there's no shared memory at all.</p>
<h2>3. When to reach for isolates</h2>
<table>
<thead>
<tr>
<th>Use case</th>
<th>Why isolates help</th>
</tr>
</thead>
<tbody><tr>
<td>Large JSON / protobuf decoding</td>
<td>Offloads CPU-heavy parsing</td>
</tr>
<tr>
<td>Image manipulation, video encoding</td>
<td>Prevents UI stutter while crunching pixels</td>
</tr>
<tr>
<td>Cryptography, compression</td>
<td>Keeps expensive math off the main isolate</td>
</tr>
<tr>
<td>Continuous background polling</td>
<td>Maintains a network loop without dropping frames</td>
</tr>
<tr>
<td>ML inference (TensorFlow Lite, on-device models)</td>
<td>Runs models without stealing frame budget</td>
</tr>
</tbody></table>
<p>The common thread across all five: CPU-bound work that takes more than a few milliseconds. Isolates have real overhead to spin up — they're not the answer for work that's already fast, or for I/O-bound waiting (a network request awaiting a response isn't blocking anything; <code>await</code> already handles that without an isolate).</p>
<h2>4. Quick win: compute() and Isolate.run() for one-shot tasks</h2>
<p>Flutter's <code>compute()</code> spawns a temporary isolate, runs a pure function on it, returns the result, and tears the isolate down automatically:</p>
<pre><code class="language-dart">Future&lt;List&lt;User&gt;&gt; loadUsers(String jsonStr) async {
  return compute(parseUsers, jsonStr);
}

List&lt;User&gt; parseUsers(String jsonStr) {
  final data = jsonDecode(jsonStr) as List&lt;dynamic&gt;;
  return data.map((e) =&gt; User.fromJson(e)).toList();
}
</code></pre>
<p>Since Dart 2.19, <code>Isolate.run()</code> does the same job with a slightly more ergonomic API — no separate top-level function required, since it accepts a closure directly:</p>
<pre><code class="language-dart">Future&lt;List&lt;User&gt;&gt; loadUsers(String jsonStr) async {
  return Isolate.run(() {
    final data = jsonDecode(jsonStr) as List&lt;dynamic&gt;;
    return data.map((e) =&gt; User.fromJson(e)).toList();
  });
}
</code></pre>
<p>Both are zero-boilerplate and correctly self-cleaning — perfect for one-off tasks under a few hundred milliseconds. <code>Isolate.run()</code> is the more current recommendation from the Dart team for new code; <code>compute()</code> remains widely used and is what you'll see in most existing Flutter codebases, so it's worth recognizing both.</p>
<h2>5. Full control: long-lived isolates, with cleanup that actually happens</h2>
<p>For streaming data or an infinite processing loop, spawn and manage the isolate yourself — but the version of this pattern worth copying has to actually release the isolate when you're done with it, not just gesture at doing so in a bullet list below the code:</p>
<pre><code class="language-dart">class BackgroundWorker {
  BackgroundWorker._(this._isolate, this._sendPort, this._receivePort);

  final Isolate _isolate;
  final SendPort _sendPort;
  final ReceivePort _receivePort;

  static Future&lt;BackgroundWorker&gt; spawn() async {
    final initPort = ReceivePort();
    final isolate = await Isolate.spawn(_entryPoint, initPort.sendPort);
    final sendPort = await initPort.first as SendPort; // handshake: get the worker's inbox

    final receivePort = ReceivePort();
    sendPort.send(receivePort.sendPort); // tell the worker where to send results back

    return BackgroundWorker._(isolate, sendPort, receivePort);
  }

  Stream&lt;dynamic&gt; get results =&gt; _receivePort;

  void send(String message) =&gt; _sendPort.send(message);

  void dispose() {
    _receivePort.close();
    _isolate.kill(priority: Isolate.immediate); // actually releases the isolate's memory
  }

  static void _entryPoint(SendPort initSendPort) {
    final commandPort = ReceivePort();
    initSendPort.send(commandPort.sendPort);

    SendPort? resultPort;
    commandPort.listen((message) {
      if (message is SendPort) {
        resultPort = message; // first message is always the result channel
      } else if (message is String) {
        final result = _doExpensiveStuff(message);
        resultPort?.send(result);
      }
    });
  }

  static String _doExpensiveStuff(String input) =&gt; input.toUpperCase(); // stand-in for real work
}
</code></pre>
<p>Usage, with the cleanup wired to the widget's own lifecycle so it can't be forgotten:</p>
<pre><code class="language-dart">class _MyScreenState extends State&lt;MyScreen&gt; {
  BackgroundWorker? _worker;

  @override
  void initState() {
    super.initState();
    BackgroundWorker.spawn().then((w) {
      setState(() =&gt; _worker = w);
      w.results.listen((result) =&gt; print('Got: $result'));
    });
  }

  @override
  void dispose() {
    _worker?.dispose(); // the isolate dies with the widget that owns it
    super.dispose();
  }
}
</code></pre>
<p>The key structural point: <code>dispose()</code> isn't a suggestion mentioned separately from the example — it's tied directly to <code>State.dispose()</code>, so there's no code path where this isolate outlives the screen that created it and quietly leaks.</p>
<h2>6. Pattern: a real isolate pool for concurrent jobs</h2>
<p>Spinning up a fresh isolate for every short job — a batch of thumbnail resizes, say — pays the isolate-startup cost repeatedly for no benefit. A pool reuses a fixed number of long-lived workers instead. Here's a minimal, working one built on the <code>BackgroundWorker</code> above rather than a black-box package:</p>
<pre><code class="language-dart">class IsolatePool {
  IsolatePool._(this._workers);

  final List&lt;BackgroundWorker&gt; _workers;
  int _nextWorker = 0;

  static Future&lt;IsolatePool&gt; spawn({int size = 3}) async {
    final workers = await Future.wait(List.generate(size, (_) =&gt; BackgroundWorker.spawn()));
    return IsolatePool._(workers);
  }

  Future&lt;dynamic&gt; submit(String task) {
    final worker = _workers[_nextWorker];
    _nextWorker = (_nextWorker + 1) % _workers.length; // round-robin dispatch
    final completer = Completer&lt;dynamic&gt;();
    late final StreamSubscription sub;
    sub = worker.results.listen((result) {
      completer.complete(result);
      sub.cancel();
    });
    worker.send(task);
    return completer.future;
  }

  void dispose() {
    for (final w in _workers) {
      w.dispose();
    }
  }
}
</code></pre>
<p><code>size: 3</code> for CPU-bound work is a reasonable default — <code>Platform.numberOfProcessors - 1</code> leaves one core free for the UI isolate itself, which is worth reserving rather than saturating every core with background work. For genuinely production-grade pooling with load balancing and error recovery built in, the <code>isolate_pool_2</code> or <code>worker_manager</code> packages on pub.dev cover more edge cases than this minimal version — but understanding the round-robin dispatch and reuse mechanism above makes debugging either package considerably easier than treating it as a black box.</p>
<h2>7. Best practices and gotchas</h2>
<table>
<thead>
<tr>
<th>Do</th>
<th>Don't</th>
</tr>
</thead>
<tbody><tr>
<td>Design isolate functions as pure — no captured mutable global state</td>
<td>Touch platform channels or UI widgets from a background isolate — they're main-isolate-only</td>
</tr>
<tr>
<td>Transfer binary data with <code>TransferableTypedData</code> for zero-copy speed</td>
<td>Pass huge object graphs across the port — flatten to primitives first</td>
</tr>
<tr>
<td>Handle errors with <code>Isolate.addErrorListener</code></td>
<td>Forget to kill isolates you spawned — leaked isolates hurt hardest on low-RAM devices</td>
</tr>
<tr>
<td>Profile with DevTools' CPU Profiler to confirm the isolate actually helped</td>
<td>Spawn isolates from within isolates unless you have a specific reason to</td>
</tr>
</tbody></table>
<h2>8. Debugging tips</h2>
<p>If frames are still dropping after moving work to an isolate, turn on <strong>Track widget rebuilds</strong> in DevTools' Inspector — if a widget is rebuilding on every frame regardless of what your isolate is doing, the isolate was never the bottleneck to begin with. If messages between isolates aren't arriving, check that both ports are still open — closing a <code>ReceivePort</code> silently ends its stream, and a common bug is closing one side too early during cleanup and being confused why <code>send()</code> calls seem to vanish. And be aware of platform lifecycle behavior: on Android, isolates spawned while the app is foregrounded can be suspended along with the rest of the app when it's backgrounded, so genuinely background work that must survive the app being closed belongs in <code>WorkManager</code> (Android) or a background fetch mechanism (iOS), not a Dart isolate.</p>
<h2>9. Putting it together — a sample architecture</h2>
<p>A concrete shape for a screen that decodes a large JSON payload from a network response without ever blocking the UI:</p>
<pre><code class="language-plaintext">User taps "Refresh"
        │
        ▼
UI isolate: HTTP request (await — non-blocking, no isolate needed here)
        │
        ▼
Response body (raw JSON string) arrives on the UI isolate
        │
        ▼
Isolate.run(() =&gt; jsonDecode + parse into model objects)
        │             ← this is the one line that actually needed an isolate
        ▼
Parsed List&lt;User&gt; returned to the UI isolate
        │
        ▼
setState() → ListView rebuilds with the new data
</code></pre>
<p>The lesson worth taking from this diagram: only the CPU-bound decode-and-parse step needed an isolate. The network request itself was already non-blocking via <code>await</code>, and wrapping it in an isolate too would have added overhead for no benefit. Isolates are for CPU-bound work specifically — not a general-purpose "make this async" tool, which <code>Future</code>/<code>await</code> already are.</p>
<h2>10. Conclusion</h2>
<p>Isolates are Dart's built-in concurrency model, and the choice between the tools above comes down to shape of the work: <code>compute()</code> or <code>Isolate.run()</code> for a one-shot CPU-bound task, a hand-managed long-lived isolate for streaming or infinite work — with cleanup tied to whatever owns it — and a small pool when you have many short jobs arriving faster than one isolate can process them. Used for the CPU-bound cases they're built for, and skipped for the I/O-bound cases <code>await</code> already handles, they're the difference between an app that stutters under load and one that doesn't.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://dart.dev/language/concurrency">Dart concurrency: isolates</a></p>
</li>
<li><p><a href="https://api.dart.dev/stable/dart-isolate/Isolate/run.html">Isolate.run() — dart:isolate API reference</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/foundation/compute.html">Flutter compute() function</a></p>
</li>
<li><p><a href="https://api.dart.dev/stable/dart-isolate/TransferableTypedData-class.html">TransferableTypedData — dart:isolate API reference</a></p>
</li>
<li><p><a href="https://developer.android.com/topic/libraries/architecture/workmanager">Background execution on Android — WorkManager</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Preventing Memory Leaks in Flutter Apps: Essential Tips]]></title><description><![CDATA[Think of your Flutter app as a workspace. Leave old papers and materials on the desk long enough and it gets cluttered, then hard to work at. A memory leak is the same idea: your app keeps holding ont]]></description><link>https://gidudunicholas.dev/preventing-memory-leaks-in-flutter-apps-essential-tips</link><guid isPermaLink="true">https://gidudunicholas.dev/preventing-memory-leaks-in-flutter-apps-essential-tips</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[Memory Leak]]></category><category><![CDATA[memory-management]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Fri, 11 Apr 2025 10:01:40 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1744365536237/32529ffb-94c0-41c3-92c8-b6bc30ef3465.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Think of your Flutter app as a workspace. Leave old papers and materials on the desk long enough and it gets cluttered, then hard to work at. A memory leak is the same idea: your app keeps holding onto an object it no longer needs, and over time that overcrowding slows the app down and, eventually, crashes it.</p>
<h2>Why memory leaks happen in Flutter</h2>
<p>Five sources account for nearly every leak you'll actually hit in a real app, and each one leaks for a specific, mechanical reason — not just "because you forgot something."</p>
<p><strong>Unclosed stream subscriptions.</strong> Listening to a stream is opening a door to receive updates. Leave the door open after you no longer need it, and two things stay alive that shouldn't: the subscription object itself, and — critically — whatever the callback closure captured, which is often the entire widget's state object, kept alive by the still-active listener referencing it.</p>
<p><strong>Undisposed controllers.</strong> <code>TextEditingController</code>, <code>AnimationController</code>, <code>ScrollController</code> all hold internal listeners and, in <code>AnimationController</code>'s case, a running <code>Ticker</code> tied to the display's frame callbacks. An undisposed <code>AnimationController</code> doesn't just waste memory — it keeps getting frame callbacks from the engine indefinitely, doing real work for a screen that no longer exists.</p>
<p><strong>Improper use of</strong> <code>GlobalKey</code><strong>.</strong> A <code>GlobalKey</code> gives external code a direct handle to a specific <code>Element</code> in the tree. If that key is stored somewhere long-lived — a static field, a singleton — it keeps that <code>Element</code>, and everything it points to, reachable from GC's perspective even after Flutter itself has removed the widget from the tree. The widget looks gone; the memory graph disagrees.</p>
<p><strong>Retaining widgets or</strong> <code>BuildContext</code><strong>.</strong> A <code>BuildContext</code> is really a reference to an <code>Element</code>. Stash one in a variable that outlives the widget — a static field, a callback registered with something long-lived — and you've done the same thing a <code>GlobalKey</code> does by accident: kept an entire subtree reachable after Flutter thinks it's disposed of.</p>
<p><strong>Global state, singletons, and static variables held too eagerly.</strong> These are convenient specifically because they live for the app's entire lifetime — which is exactly the property that turns "convenient" into "leak" the moment one of them accumulates references to short-lived objects (a list of every <code>User</code> ever fetched, say) that were supposed to be temporary.</p>
<h2>Fixing each one, with code you can actually run</h2>
<h3>1. Dispose resources properly</h3>
<pre><code class="language-dart">class _MyWidgetState extends State&lt;MyWidget&gt; {
  final _controller = TextEditingController();
  StreamSubscription&lt;Event&gt;? _subscription;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _subscription = myStream.listen((event) { /* handle event */ });
  }

  @override
  void dispose() {
    _controller.dispose();
    _subscription?.cancel(); // ?. — this field is nullable, and cancel() called on null is a no-op, not a crash
    _timer?.cancel();
    super.dispose();
  }
}
</code></pre>
<p>The <code>?.</code> on <code>_subscription</code> matters for a reason beyond style: because <code>_subscription</code> is typed <code>StreamSubscription?</code>, calling <code>.cancel()</code> on it directly without the null-aware operator won't compile at all — Dart won't let you call a method on a nullable reference without either a null check or the <code>?.</code> operator. It's a one-character fix, but it's the exact kind of code that looks plausible in a tutorial and fails the moment you paste it in.</p>
<h3>2. Use StatefulWidget responsibly</h3>
<p>Keep only what the widget genuinely needs in its <code>State</code> — a cached, large <code>List&lt;Product&gt;</code> sitting in a widget's state long after the list view has scrolled past it is memory held for no reason. If data needs to outlive the widget, that's a sign it belongs in a repository or a provider, not the widget's own state.</p>
<h3>3. Handle BuildContext with care</h3>
<p>Never store a <code>BuildContext</code> for later use across an <code>await</code>. Check <code>mounted</code> immediately before using it:</p>
<pre><code class="language-dart">Future&lt;void&gt; _submit() async {
  await api.save(formData);
  if (!mounted) return; // the widget may have been disposed while we awaited
  Navigator.of(context).pop();
}
</code></pre>
<p>The <code>mounted</code> check exists precisely because the <code>await</code> above can outlive the widget — the user might navigate away while the save request is in flight, and using <code>context</code> after that point doesn't just risk a leak, it can throw at runtime.</p>
<h3>4. Limit use of GlobalKeys — and here's what to use instead</h3>
<p>If a <code>GlobalKey</code> is being used to read a <code>TextField</code>'s value or call a method on a child widget, a callback usually does the same job without the retention risk:</p>
<pre><code class="language-dart">// Leak-prone: storing a GlobalKey somewhere long-lived to reach into a widget later
final formKey = GlobalKey&lt;FormState&gt;(); // fine if scoped to one widget's lifetime, risky if stored globally

// Preferred where possible: let the child report back via a callback
class SearchField extends StatefulWidget {
  const SearchField({super.key, required this.onSubmitted});
  final ValueChanged&lt;String&gt; onSubmitted;
  // ...
}
</code></pre>
<p>A <code>GlobalKey</code> scoped to a single widget's own field, created and disposed alongside it, is fine — the leak risk is specifically when a key is stored somewhere that outlives the widget it points to, like a static field or a singleton's map.</p>
<h3>5. Manage dependency-injected objects diligently</h3>
<p>Whatever DI tool you're using, the objects it hands out don't dispose themselves — you still own that lifecycle. With <code>Provider</code>, <code>dispose</code> is a constructor parameter and gets called automatically when the provider leaves the widget tree:</p>
<pre><code class="language-dart">ChangeNotifierProvider&lt;SearchController&gt;(
  create: (_) =&gt; SearchController(),
  dispose: (_, controller) =&gt; controller.dispose(),
  child: const SearchScreen(),
)
</code></pre>
<p>With <code>Riverpod</code>, <code>ref.onDispose</code> inside the provider itself is the equivalent hook:</p>
<pre><code class="language-dart">final searchControllerProvider = Provider&lt;SearchController&gt;((ref) {
  final controller = SearchController();
  ref.onDispose(controller.dispose);
  return controller;
});
</code></pre>
<p>With <code>GetIt</code>, a <code>registerFactory</code> (new instance per request) leaves disposal to whoever holds the instance, while <code>registerSingleton</code> lives for the app's lifetime by design — the leak risk with <code>GetIt</code> specifically is registering something screen-scoped as a singleton, which keeps it alive long after the screen that needed it is gone. Unregister it explicitly (<code>getIt.unregister&lt;SearchController&gt;()</code>) when the scope that needed it ends, if it was never meant to be app-lifetime in the first place.</p>
<h3>6. Profile your app before assuming it's fine</h3>
<p>Periodically checking memory usage, rather than only when a user complains, catches leaks while they're cheap to fix.</p>
<h2>A worked example: catching a leak in DevTools</h2>
<p>Here's a screen that leaks on every visit — an <code>AnimationController</code> created without being disposed:</p>
<pre><code class="language-dart">class PulseWidget extends StatefulWidget {
  const PulseWidget({super.key});
  @override
  State&lt;PulseWidget&gt; createState() =&gt; _PulseWidgetState();
}

class _PulseWidgetState extends State&lt;PulseWidget&gt; with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this, // requires the TickerProviderStateMixin above — without it, this line won't compile
    duration: const Duration(seconds: 1),
  )..repeat();

  @override
  Widget build(BuildContext context) =&gt; FadeTransition(opacity: _controller, child: const FlutterLogo());

  // dispose() intentionally omitted here to demonstrate the leak
}
</code></pre>
<p>Note the <code>with SingleTickerProviderStateMixin</code> — this is a real, separate requirement from disposal that's easy to miss: <code>AnimationController</code> needs a <code>TickerProvider</code> (the <code>vsync</code> parameter) to know when to run, and Flutter won't let you construct one in a <code>State</code> class without that mixin. It's a compile-time guardrail that catches one class of mistake; it does nothing to catch the missing <code>dispose()</code> call, which is a runtime problem only profiling will show you.</p>
<p>To actually see the leak: open DevTools' <strong>Memory</strong> tab, navigate to the screen containing <code>PulseWidget</code>, navigate away, and take a snapshot. Repeat that navigate-in/navigate-out cycle three or four times, taking a snapshot after each round trip. Each <code>_PulseWidgetState</code> instance should have been garbage collected once its screen was popped — instead, the snapshot's instance count for <code>_PulseWidgetState</code> and <code>AnimationController</code> climbs by one with every cycle, because the running <code>Ticker</code> inside the undisposed controller keeps a live reference back to the state object that created it. That climbing count, specifically across repeated identical navigation cycles rather than in a single snapshot, is the signature of a leak as opposed to normal memory use.</p>
<p>Add the missing <code>dispose()</code>:</p>
<pre><code class="language-dart">@override
void dispose() {
  _controller.dispose();
  super.dispose();
}
</code></pre>
<p>Repeat the same navigate-in/navigate-out/snapshot cycle, and the instance count for both classes returns to zero after each round trip instead of climbing — the same test, run against the fixed code, is what confirms the fix actually worked rather than just looking reasonable.</p>
<h2>Tools for detecting leaks</h2>
<p><strong>Flutter DevTools' Memory tab</strong> is the primary tool — heap snapshots, allocation tracking, and (in current DevTools versions) leak-specific views that flag objects still reachable after their expected disposal point. Check the current DevTools documentation for the exact tab layout, since this tooling has genuinely changed shape across versions and a screenshot from an older guide may not match what you see.</p>
<p><strong>The</strong> <code>leak_tracker</code> <strong>package</strong>, which DevTools' own leak detection is built on, can be wired into widget tests directly, catching a leaked controller in CI before it ever reaches a real device.</p>
<p><code>dart:developer</code><strong>'s lower-level VM service APIs</strong> remain available for anyone who wants to script memory analysis rather than working through the DevTools UI, though for the vast majority of day-to-day debugging the Memory tab is the faster path.</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Issue</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Unclosed <code>StreamSubscription</code></td>
<td><code>_subscription?.cancel()</code> in <code>dispose()</code></td>
</tr>
<tr>
<td>Undisposed controllers</td>
<td>Call <code>.dispose()</code> in <code>dispose()</code>; remember <code>AnimationController</code> also needs <code>vsync</code> from a <code>TickerProviderStateMixin</code></td>
</tr>
<tr>
<td>Retained <code>BuildContext</code></td>
<td>Check <code>mounted</code> before any use after an <code>await</code></td>
</tr>
<tr>
<td><code>GlobalKey</code> stored long-lived</td>
<td>Prefer a callback; scope any <code>GlobalKey</code> to the widget that owns it</td>
</tr>
<tr>
<td>DI-provided objects</td>
<td>Wire disposal into your DI tool's own hook (<code>dispose:</code> for Provider, <code>ref.onDispose</code> for Riverpod, explicit <code>unregister</code> for GetIt)</td>
</tr>
<tr>
<td>Singleton holding stale references</td>
<td>Clear collections explicitly, or scope the object's lifetime instead of making it a true singleton</td>
</tr>
</tbody></table>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.flutter.dev/tools/devtools/memory">Flutter DevTools: Memory page</a></p>
</li>
<li><p><a href="https://pub.dev/packages/leak_tracker">leak_tracker package on pub.dev</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/animation/AnimationController-class.html">AnimationController and TickerProvider</a></p>
</li>
<li><p><a href="https://pub.dev/packages/provider">Provider package: dispose parameter</a></p>
</li>
<li><p><a href="https://riverpod.dev/docs/concepts/provider_lifecycles">Riverpod: ref.onDispose</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Understanding Route Observers in Flutter]]></title><description><![CDATA[RouteObserver is Flutter's built-in way to find out when a screen is pushed, popped, or returned to — without that screen having to poll anything or wire up custom callbacks to every place navigation ]]></description><link>https://gidudunicholas.dev/understanding-route-observers-in-flutter</link><guid isPermaLink="true">https://gidudunicholas.dev/understanding-route-observers-in-flutter</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[observers]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Thu, 03 Apr 2025 09:10:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1743671148962/2ac6f5a5-ee54-45fb-b234-a4d49b3924e9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>RouteObserver</code> is Flutter's built-in way to find out when a screen is pushed, popped, or returned to — without that screen having to poll anything or wire up custom callbacks to every place navigation might happen. It's the mechanism behind a specific, common need: knowing when a user has come <em>back</em> to a screen, not just when it's first built.</p>
<h2>Setting it up</h2>
<p>Three steps, and all three have to be done for any of it to fire.</p>
<p><strong>1. Create a</strong> <code>RouteObserver</code> <strong>instance</strong>, once, somewhere it can be shared:</p>
<pre><code class="language-dart">final RouteObserver&lt;PageRoute&gt; routeObserver = RouteObserver&lt;PageRoute&gt;();
</code></pre>
<p><strong>2. Register it on the</strong> <code>Navigator</code> <strong>that owns the routes you care about:</strong></p>
<pre><code class="language-dart">MaterialApp(
  navigatorObservers: [routeObserver],
  home: const MyHomePage(),
);
</code></pre>
<p><strong>3. Implement</strong> <code>RouteAware</code> <strong>on any widget that wants to know about route changes, and subscribe it:</strong></p>
<pre><code class="language-dart">class MyScreen extends StatefulWidget {
  const MyScreen({super.key});
  @override
  State&lt;MyScreen&gt; createState() =&gt; _MyScreenState();
}

class _MyScreenState extends State&lt;MyScreen&gt; with RouteAware {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    routeObserver.subscribe(this, ModalRoute.of(context)! as PageRoute);
  }

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPush() =&gt; debugPrint('MyScreen pushed');

  @override
  void didPop() =&gt; debugPrint('MyScreen popped');

  @override
  void didPopNext() =&gt; debugPrint('Returned to MyScreen');

  @override
  void didPushNext() =&gt; debugPrint('Navigated away from MyScreen');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Route Observer Example')),
      body: const Center(child: Text('Listening to navigation changes')),
    );
  }
}
</code></pre>
<p>The <code>subscribe</code>/<code>unsubscribe</code> pair here is doing real work and is worth not skipping: subscribing without ever unsubscribing keeps this widget referenced by the observer indefinitely, which is exactly the kind of leak covered in the memory-leaks piece on this blog — pairing <code>subscribe()</code> in <code>didChangeDependencies()</code> with <code>unsubscribe()</code> in <code>dispose()</code> closes that gap the same way any other controller needs its <code>dispose()</code> call.</p>
<h2>A worked example: refresh a list when the user comes back to it</h2>
<p>The four callbacks are easy to list and easy to leave abstract. Here's the one that earns its place in almost every real app — refreshing a list screen's data specifically when the user returns to it after editing something on the next screen, rather than on every possible trigger:</p>
<pre><code class="language-dart">class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});
  @override
  State&lt;TaskListScreen&gt; createState() =&gt; _TaskListScreenState();
}

class _TaskListScreenState extends State&lt;TaskListScreen&gt; with RouteAware {
  late Future&lt;List&lt;Task&gt;&gt; _tasks;

  @override
  void initState() {
    super.initState();
    _tasks = taskRepository.fetchAll();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    routeObserver.subscribe(this, ModalRoute.of(context)! as PageRoute);
  }

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPopNext() {
    // Fires specifically when the user returns here from a pushed screen —
    // not on first load, and not on every rebuild. Exactly the moment a
    // task might have been edited on the screen we're returning from.
    setState(() =&gt; _tasks = taskRepository.fetchAll());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tasks')),
      body: FutureBuilder&lt;List&lt;Task&gt;&gt;(
        future: _tasks,
        builder: (context, snapshot) {
          if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
          return ListView.builder(
            itemCount: snapshot.data!.length,
            itemBuilder: (context, i) =&gt; ListTile(title: Text(snapshot.data![i].title)),
          );
        },
      ),
    );
  }
}
</code></pre>
<p>This is more targeted than the alternatives a lot of teams reach for first — refreshing on every <code>build()</code> call (wasteful, refetches on rebuilds that have nothing to do with navigation) or passing a callback through <code>Navigator.push</code>'s return value (works, but only for the one specific push site; <code>didPopNext()</code> fires no matter how many different screens might have navigated away from and back to this one).</p>
<h2>The gotcha that breaks this silently: nested navigators</h2>
<p><code>RouteObserver</code> only reports on routes pushed through the specific <code>Navigator</code> it was registered on. This is the single most common reason a <code>RouteAware</code> implementation appears to do nothing — a bottom navigation bar, a tabbed interface, or any <code>Navigator</code> nested inside another one has its own independent route stack, and the app-level observer registered on <code>MaterialApp</code> never sees pushes and pops that happen inside that nested <code>Navigator</code> at all.</p>
<pre><code class="language-dart">// This nested Navigator has its own stack — the app-level routeObserver
// registered on MaterialApp will NOT see pushes/pops happening inside it.
Navigator(
  key: _tabNavigatorKey,
  onGenerateRoute: (settings) =&gt; MaterialPageRoute(
    settings: settings,
    builder: (context) =&gt; const TabContentScreen(),
  ),
)
</code></pre>
<p>If a screen living inside that nested navigator needs <code>RouteAware</code> callbacks, register a second <code>RouteObserver</code> specifically on that nested <code>Navigator</code>'s own <code>observers</code> list, and subscribe to that one instead of the app-level instance. Widgets inside the nested stack have no visibility into the outer one and vice versa — treat each <code>Navigator</code> as needing its own observer, not one shared instance covering the whole app.</p>
<h2>Use cases, made concrete</h2>
<p><strong>Analytics and tracking</strong>: log a screen-view event from <code>didPush()</code> (first arrival) separately from <code>didPopNext()</code> (a return visit), since most analytics tools distinguish the two and conflating them undercounts genuine repeat views.</p>
<p><strong>Refreshing data on return</strong>, as shown above — the most common legitimate use of <code>didPopNext()</code>.</p>
<p><strong>Pause/resume for screen-specific work</strong>: stop a video or an animation in <code>didPushNext()</code> (the user navigated away, so it shouldn't keep consuming resources off-screen) and resume it in <code>didPopNext()</code>. This is a narrower, route-specific version of what <code>WidgetsBindingObserver</code>'s <code>didChangeAppLifecycleState</code> does at the whole-app level — reach for <code>RouteAware</code> when you need to know about navigation to a <em>specific screen</em>, and <code>WidgetsBindingObserver</code> when you need to know the app itself was backgrounded or foregrounded; they answer different questions and are often used together, not as alternatives to each other.</p>
<h2>What GoRouter users actually need to do differently</h2>
<p>There's no separate <code>GoRouterObserver</code> class — GoRouter's <code>GoRouter</code> constructor accepts a standard <code>observers:</code> parameter that takes the same <code>NavigatorObserver</code> list <code>MaterialApp</code> does, so a <code>RouteObserver&lt;PageRoute&gt;</code> you already built works directly:</p>
<pre><code class="language-dart">final GoRouter router = GoRouter(
  observers: [routeObserver], // the same RouteObserver instance from above
  routes: [ /* your routes */ ],
);
</code></pre>
<p>The real gotcha with GoRouter is more specific than "use a different class": if your routing uses <code>ShellRoute</code> (the common pattern for a persistent bottom nav bar with GoRouter), the standard observer stops firing for routes inside that shell — this is a known, currently open limitation, not a configuration mistake on your part. If you're hitting a <code>RouteAware</code> callback that silently never fires and you're using <code>ShellRoute</code>, that's very likely why, and the workaround is listening to <code>GoRouterDelegate</code>'s own listenable directly rather than relying on the standard observer path — worth checking GoRouter's own issue tracker for the current state of a built-in fix before building a workaround, since this is exactly the kind of thing that gets resolved between versions.</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Need</th>
<th>Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Know when the user returns to a specific screen</td>
<td><code>RouteAware.didPopNext()</code></td>
</tr>
<tr>
<td>Know when the user navigates away from a specific screen</td>
<td><code>RouteAware.didPushNext()</code></td>
</tr>
<tr>
<td>Know when the whole app is backgrounded/foregrounded</td>
<td><code>WidgetsBindingObserver.didChangeAppLifecycleState</code></td>
</tr>
<tr>
<td>Route-aware behavior inside a nested <code>Navigator</code> (tabs, bottom nav)</td>
<td>A separate <code>RouteObserver</code> registered on that nested <code>Navigator</code></td>
</tr>
<tr>
<td>Route-aware behavior with GoRouter</td>
<td>Same <code>RouteObserver</code>, passed to GoRouter's <code>observers:</code> — but check for the <code>ShellRoute</code> limitation first</td>
</tr>
</tbody></table>
<p>Try wiring <code>didPopNext()</code> into a screen that needs to refresh on return, and it earns its place quickly — it's a small amount of setup for the specific problem it solves, once you know where the nested-navigator and GoRouter-shell edge cases actually are.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://api.flutter.dev/flutter/widgets/RouteObserver-class.html">RouteObserver — Flutter API documentation</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/RouteAware-class.html">RouteAware — Flutter API documentation</a></p>
</li>
<li><p><a href="https://pub.dev/documentation/go_router/latest/go_router/GoRouter/GoRouter.html">GoRouter: observers configuration</a></p>
</li>
<li><p><a href="https://github.com/flutter/flutter/issues/121866">GoRouter ShellRoute and NavigatorObserver limitation</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html">WidgetsBindingObserver — Flutter API documentation</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Improve Your App's Performance: 5 Effective Tips]]></title><description><![CDATA[Improving performance isn't about fancy tech — it's about a handful of high-leverage habits, applied consistently. Here are the five that matter most, each with enough code to actually show the mechan]]></description><link>https://gidudunicholas.dev/improve-your-app-s-performance-5-effective-tips</link><guid isPermaLink="true">https://gidudunicholas.dev/improve-your-app-s-performance-5-effective-tips</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[Performance Optimization]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Sat, 15 Mar 2025 11:24:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1742037712163/2b3a8301-2fde-4bf0-ac79-ea4fe2030782.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Improving performance isn't about fancy tech — it's about a handful of high-leverage habits, applied consistently. Here are the five that matter most, each with enough code to actually show the mechanism rather than just name it. If any one of these grabs you, I've gone deeper on rebuilds, DevTools, and isolates in separate posts linked at the end.</p>
<h2>1. Use const constructors — and see what they actually skip</h2>
<p><code>const</code> doesn't just save a little compile-time work — it tells Flutter the widget and everything inside it are identical to last time, so the whole subtree gets skipped during a rebuild rather than rebuilt and found unchanged. The difference is easiest to see with a debug print on both a <code>const</code> and a non-<code>const</code> sibling:</p>
<pre><code class="language-dart">class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  @override
  State&lt;HomeScreen&gt; createState() =&gt; _HomeScreenState();
}

class _HomeScreenState extends State&lt;HomeScreen&gt; {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          ElevatedButton(
            onPressed: () =&gt; setState(() =&gt; count++),
            child: Text('Count: $count'), // not const — reads live state, must rebuild
          ),
          const ExpensiveHeader(), // const — Flutter skips rebuilding this entirely
        ],
      ),
    );
  }
}

class ExpensiveHeader extends StatelessWidget {
  const ExpensiveHeader({super.key});
  @override
  Widget build(BuildContext context) {
    debugPrint('ExpensiveHeader build'); // never prints again after the first frame
    return const Text('Welcome back');
  }
}
</code></pre>
<p>Tap the button repeatedly and <code>ExpensiveHeader build</code> prints exactly once — not once per tap. The <code>Text('Count: $count')</code> line necessarily rebuilds because it reads <code>count</code>; <code>ExpensiveHeader</code> doesn't, and marking it <code>const</code> is what lets Flutter prove that to itself and skip the work rather than redo it and get the same answer.</p>
<h2>2. Scope rebuilds with Selector, not just "use a state management library"</h2>
<p>Reaching for Provider or Riverpod doesn't automatically fix rebuild scope — a widget that calls <code>context.watch&lt;CartModel&gt;()</code> rebuilds on <em>any</em> change to <code>CartModel</code>, even a field it doesn't render. <code>Selector</code> (Provider) and <code>.select()</code> (Riverpod) narrow that down to the one field actually being displayed:</p>
<pre><code class="language-dart">// Rebuilds on ANY change to CartModel, even unrelated ones
Widget buildBadge(BuildContext context) {
  final cart = context.watch&lt;CartModel&gt;();
  return Text('${cart.itemCount}');
}

// Rebuilds ONLY when itemCount specifically changes
Widget buildBadge(BuildContext context) {
  return Selector&lt;CartModel, int&gt;(
    selector: (context, cart) =&gt; cart.itemCount,
    builder: (context, itemCount, child) =&gt; Text('$itemCount'),
  );
}
</code></pre>
<p>If <code>CartModel</code> also tracks a shipping address or a discount code, the first version rebuilds this badge every time either of those changes for no visible reason; the second doesn't. The rule worth remembering: the state management library decides <em>how</em> state is shared, but <code>Selector</code>/<code>.select()</code> is what actually decides <em>how much</em> rebuilds when it changes — and skipping that step is the most common way teams adopt Provider or Riverpod and see no rebuild improvement at all.</p>
<h2>3. Profile before you guess — with the actual commands</h2>
<p>Performance hiccups are rarely where intuition points. Run in profile mode, not debug mode, since debug mode carries extra overhead that skews the numbers:</p>
<pre><code class="language-bash">flutter run --profile
</code></pre>
<p>Open DevTools (via your IDE's "Open DevTools" button, or <code>dart pub global run devtools</code> from the CLI) and go to the <strong>Performance</strong> tab. Reproduce the slow interaction, then read the frame chart: a bar with a tall <em>UI-thread</em> segment points at your <code>build()</code> methods doing too much; a tall <em>raster-thread</em> segment points at images or shader work instead. That distinction changes which of tips 1, 2, or 4 actually applies — optimizing <code>build()</code> methods won't help a raster-bound problem, and vice versa. [I've gone through a full worked example of this, including catching a real dropped frame, in a separate post on Flutter DevTools.]</p>
<h2>4. Streamline assets with the settings that actually matter</h2>
<p><code>FadeInImage</code> makes an image transition look smoother, but it doesn't address the bigger cost: decoding a full-resolution image into memory just to display it at a fraction of that size. <code>cacheWidth</code>/<code>cacheHeight</code> fix the actual cost, not just the visual polish:</p>
<pre><code class="language-dart">Image.asset(
  'assets/banner.png',
  cacheWidth: 400, // decode at display size, not the source file's full resolution
  cacheHeight: 200,
)
</code></pre>
<p>A 4000×2000 source image displayed in a 400×200 tile costs roughly 100x the memory to decode without this — <code>cacheWidth</code>/<code>cacheHeight</code> tell Flutter's image cache to decode at the size you'll actually render, which is almost always the single highest-leverage image fix available, ahead of compressing the source file itself.</p>
<p>For genuinely heavy work — image manipulation, large JSON parsing — move it off the UI thread entirely:</p>
<pre><code class="language-dart">Future&lt;Uint8List&gt; resizeInBackground(Uint8List bytes) {
  return compute(_resize, bytes);
}

Uint8List _resize(Uint8List bytes) {
  // decode, resize, re-encode — all off the UI isolate
  return processedBytes;
}
</code></pre>
<p>[<code>compute()</code> and its newer counterpart <code>Isolate.run()</code> are covered in more depth, including when the overhead of an isolate is and isn't worth it, in a separate post on Dart isolates.]</p>
<h2>5. Simplify the widget tree — and see the difference in the Inspector</h2>
<p>A deeply nested tree costs real layout time, since Flutter has to walk every level to compute constraints. The fix is usually flattening redundant wrapper widgets, not adding new abstraction:</p>
<pre><code class="language-dart">// Before: three nested widgets doing the job of one
Container(
  padding: const EdgeInsets.all(8),
  child: Center(
    child: Container(
      child: const Text('Hello'),
    ),
  ),
)

// After: same visual result, one widget
const Padding(
  padding: EdgeInsets.all(8),
  child: Center(child: Text('Hello')),
)
</code></pre>
<p>Open DevTools' <strong>Flutter Inspector</strong>, enable <strong>Show guidelines</strong>, and compare the widget tree depth before and after a change like this on a real screen — a tree that's meaningfully shallower after removing redundant <code>Container</code>s and <code>SizedBox</code>es is a concrete, visible confirmation that the simplification actually reduced structure, not just line count.</p>
<h2>Wrapping up</h2>
<p>Five habits, roughly in order of how often they matter: mark static subtrees <code>const</code>, scope rebuilds to the specific field a widget renders rather than the whole model, profile instead of guessing which of the two applies, decode images at display size rather than source size, and flatten a widget tree that's grown more nested than the UI it produces requires. Each one is small on its own; applied together across a real app, they compound.</p>
<p>For more depth than a five-tip roundup can cover: the mechanics of <em>why</em> rebuilds cascade the way they do are in <a href="https://gidudunicholas.dev/the-hidden-cost-of-rebuilds-in-flutter">The Hidden Cost of Rebuilds in Flutter</a>, a full DevTools walkthrough with a worked jank-hunting example is in <a href="https://gidudunicholas.dev/a-friendlier-guide-to-taming-flutter-jank-with-devtools">A Friendlier Guide to Taming Flutter Jank with DevTools</a>, and when an isolate is and isn't worth its overhead is covered in the isolates post on this blog.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.flutter.dev/perf/best-practices">Flutter performance best practices</a></p>
</li>
<li><p><a href="https://pub.dev/documentation/provider/latest/provider/Selector-class.html">Selector — provider package documentation</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/Image-class.html">Image.cacheWidth and cacheHeight</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/tools/devtools/performance">Flutter DevTools: Performance page</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/tools/devtools/inspector">Flutter DevTools: Widget Inspector</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Understanding Streams in Dart: A Complete Guide]]></title><description><![CDATA[Streams are one of the foundations of asynchronous programming in Dart. In its simplest form, a stream is a sequence of asynchronous events — a single value or a whole collection, arriving over time r]]></description><link>https://gidudunicholas.dev/understanding-streams-in-dart-a-complete-guide</link><guid isPermaLink="true">https://gidudunicholas.dev/understanding-streams-in-dart-a-complete-guide</guid><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Sun, 26 Jan 2025 06:05:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1737871464451/1bef24c3-d153-4c7a-a911-dc7608eb0b60.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Streams are one of the foundations of asynchronous programming in Dart. In its simplest form, a stream is a sequence of asynchronous events — a single value or a whole collection, arriving over time rather than all at once. Streams are how Dart and Flutter apps model user interactions, file I/O, and API responses that don't resolve in a single step.</p>
<h2>Key concepts</h2>
<p><strong>Stream</strong>: the source of asynchronous data — a sequence of events that arrive over time.</p>
<p><strong>StreamController</strong>: the object that manages a stream and lets you add events, errors, and a "done" signal to it.</p>
<p><strong>StreamSubscription</strong>: what <code>listen()</code> returns — the handle representing an active listener, which is also what you cancel when you're done.</p>
<p>Streams come in two flavors: <strong>single-subscription</strong> streams allow exactly one listener over their lifetime (the default, used for one-shot things like an API response), and <strong>broadcast</strong> streams allow any number of simultaneous listeners (used for shared, ongoing data like a connectivity status or a chat message feed).</p>
<h2>Creating a stream</h2>
<p><strong>Using</strong> <code>StreamController</code> — the general-purpose way to build a custom stream:</p>
<pre><code class="language-dart">void main() {
  final controller = StreamController&lt;int&gt;();
  final stream = controller.stream;

  final subscription = stream.listen((data) {
    print('Data received: $data');
  });

  controller.sink.add(1);
  controller.sink.add(2);

  controller.close(); // signals "done" — no more events will arrive
  subscription.cancel(); // releases the listener; see "cleaning up" below
}
</code></pre>
<p><strong>Using</strong> <code>Stream.fromIterable</code> — for turning an existing collection into a stream:</p>
<pre><code class="language-dart">final stream = Stream.fromIterable([1, 2, 3, 4, 5]);
stream.listen((event) =&gt; print('Event: $event'));
</code></pre>
<p><strong>Using</strong> <code>Stream.periodic</code> — for events on a timer:</p>
<pre><code class="language-dart">final stream = Stream.periodic(const Duration(seconds: 1), (count) =&gt; count);
stream.take(5).listen((event) =&gt; print('Periodic event: $event'));
</code></pre>
<p>Note <code>take(5)</code> here — without it, <code>Stream.periodic</code> never stops on its own, which matters later when combining streams.</p>
<h2>Listening to streams</h2>
<p><code>listen()</code> returns a <code>StreamSubscription</code>, and that return value isn't just for show — it's what you cancel later:</p>
<pre><code class="language-dart">final stream = Stream.fromIterable([1, 2, 3]);

final subscription = stream.listen(
  (data) =&gt; print('Data: $data'),
  onDone: () =&gt; print('Stream closed'),
);
</code></pre>
<h2>Transforming streams</h2>
<p><strong>Mapping</strong> — transform each event:</p>
<pre><code class="language-dart">stream.map((event) =&gt; event * 2).listen((data) =&gt; print('Mapped: $data'));
</code></pre>
<p><strong>Filtering</strong> — pass through only matching events:</p>
<pre><code class="language-dart">stream.where((event) =&gt; event % 2 == 0).listen((data) =&gt; print('Even: $data'));
</code></pre>
<p><strong>Reducing</strong> — collapse the whole stream into a single value once it completes:</p>
<pre><code class="language-dart">stream.reduce((acc, curr) =&gt; acc + curr).then((sum) =&gt; print('Sum: $sum'));
</code></pre>
<p><code>reduce</code> only resolves once the stream is done — it won't work on a stream that never completes, like an un-<code>take</code>'d <code>Stream.periodic</code>.</p>
<h2>Handling errors</h2>
<pre><code class="language-dart">stream.listen(
  (data) =&gt; print('Data: $data'),
  onError: (error) =&gt; print('Error: $error'),
  onDone: () =&gt; print('Stream completed'),
);
</code></pre>
<p>An unhandled error on a stream doesn't crash your app the way an unhandled exception in synchronous code might, but it does silently stop delivering further events to that listener unless <code>cancelOnError: false</code> is passed to <code>listen()</code> — worth knowing explicitly, since the default behavior surprises people the first time a single bad event ends an otherwise-healthy stream.</p>
<h2>Combining multiple streams — done correctly</h2>
<p><strong>Merging</strong> two streams — combining their events into one stream, interleaved as they actually arrive, not the events from one stream followed by the events from the other. The straightforward-looking approach using <code>Stream.fromFutures</code> and <code>.toList()</code> doesn't do this: <code>.toList()</code> waits for a stream to fully complete before it produces anything, so "merging" two streams that way just plays one stream's full output followed by the other's, and it silently never resolves at all if either stream is unbounded (like a <code>Stream.periodic</code> without <code>.take()</code>).</p>
<p>The correct tool is <code>StreamGroup</code> from Dart's own <code>async</code> package:</p>
<pre><code class="language-dart">import 'package:async/async.dart';

Stream&lt;int&gt; stream1 = Stream.fromIterable([1, 2, 3]);
Stream&lt;int&gt; stream2 = Stream.periodic(const Duration(milliseconds: 500), (i) =&gt; i + 10).take(3);

final merged = StreamGroup.merge([stream1, stream2]);
merged.listen((event) =&gt; print('Merged: $event'));
</code></pre>
<p>Events from both sources now arrive on <code>merged</code> genuinely interleaved by real arrival time, and it works correctly even when one of the sources is a long-lived, never-completing stream — which is the entire point of merging in the first place.</p>
<p><strong>Zipping</strong> — pairing up the <em>n</em>th event from each stream — isn't part of Dart's standard library. <code>rxdart</code>'s <code>Rx.zip2</code> is the standard tool:</p>
<pre><code class="language-dart">import 'package:rxdart/rxdart.dart';

Rx.zip2(stream1, stream2, (int a, int b) =&gt; a + b)
    .listen((sum) =&gt; print('Zipped sum: $sum'));
</code></pre>
<h2>Async generators</h2>
<p>The <code>async*</code> keyword is Dart's built-in way to write a stream as a function, using <code>yield</code> instead of manually managing a <code>StreamController</code>:</p>
<pre><code class="language-dart">Stream&lt;int&gt; generateNumbers(int max) async* {
  for (int i = 1; i &lt;= max; i++) {
    yield i;
    await Future.delayed(const Duration(seconds: 1));
  }
}

generateNumbers(5).listen(print);
</code></pre>
<p>This is usually the cleaner choice over a raw <code>StreamController</code> whenever the stream's logic is "loop, wait, produce a value" — you get cancellation and backpressure handling for free instead of managing them by hand.</p>
<h2>Consuming a stream in Flutter: StreamBuilder</h2>
<p>The single most common place a Flutter developer actually touches a stream isn't a manual <code>listen()</code> call — it's <code>StreamBuilder</code>, which rebuilds a widget automatically each time the stream emits, and manages subscription and cancellation for you as the widget is built and disposed:</p>
<pre><code class="language-dart">class CounterDisplay extends StatelessWidget {
  const CounterDisplay({super.key, required this.stream});
  final Stream&lt;int&gt; stream;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder&lt;int&gt;(
      stream: stream,
      initialData: 0,
      builder: (context, snapshot) {
        if (snapshot.hasError) return Text('Error: ${snapshot.error}');
        return Text('Count: ${snapshot.data}');
      },
    );
  }
}
</code></pre>
<p><code>StreamBuilder</code> subscribes when it's built and unsubscribes automatically when it's removed from the tree — this is why it's the preferred way to consume a stream directly in UI code, and manual <code>listen()</code> calls are more for streams you're combining or transforming in a service layer, not directly rendering.</p>
<h2>Cleaning up — the part "always close controllers" needs to actually show</h2>
<p>Every manual <code>listen()</code> call in this guide returns a <code>StreamSubscription</code>, and that subscription needs to be cancelled when you're done with it, the same way a <code>TextEditingController</code> needs <code>.dispose()</code> — a subscription left open keeps its callback (and whatever it captured) alive indefinitely:</p>
<pre><code class="language-dart">class _MyWidgetState extends State&lt;MyWidget&gt; {
  StreamSubscription&lt;int&gt;? _subscription;

  @override
  void initState() {
    super.initState();
    _subscription = someStream.listen((data) { /* ... */ });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}
</code></pre>
<p>And any <code>StreamController</code> you create yourself needs <code>.close()</code> called on it once nothing will add to it anymore — typically also in <code>dispose()</code> if it's owned by a widget's state, or whenever the service or repository that created it is done being used.</p>
<h2>Best practices</h2>
<p><strong>Close every</strong> <code>StreamController</code> <strong>you create</strong>, and <strong>cancel every</strong> <code>StreamSubscription</code> <strong>you manually</strong> <code>listen()</code> <strong>to</strong> — these are two separate objects with two separate cleanup calls, and skipping either one leaks.</p>
<p><strong>Prefer</strong> <code>StreamBuilder</code> <strong>for UI consumption</strong> over manual <code>listen()</code> calls inside widgets, since it handles the subscribe/unsubscribe lifecycle automatically and removes an entire category of leak risk.</p>
<p><strong>Use broadcast streams specifically for shared, ongoing data</strong> — a connectivity status, a chat feed — and single-subscription streams for one-shot data, like a single API call's response.</p>
<p><strong>Reach for</strong> <code>async*</code> <strong>generators over a raw</strong> <code>StreamController</code> whenever the underlying logic is a loop that produces values over time — it's less code and gets cancellation handling for free.</p>
<p><strong>Use</strong> <code>StreamGroup</code> <strong>(from</strong> <code>async</code><strong>) or</strong> <code>rxdart</code> <strong>for combining streams</strong> rather than hand-rolling it with <code>Future</code>-based tricks — merging and zipping have real edge cases (unbounded streams, error propagation) that these packages have already solved correctly.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://dart.dev/libraries/async/using-streams">Dart: asynchronous programming with streams</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html">StreamBuilder — Flutter API documentation</a></p>
</li>
<li><p><a href="https://pub.dev/documentation/async/latest/async/StreamGroup-class.html">async package — StreamGroup</a></p>
</li>
<li><p><a href="https://pub.dev/packages/rxdart">rxdart package on pub.dev</a></p>
</li>
<li><p><a href="https://api.dart.dev/stable/dart-async/StreamController-class.html">StreamController — dart:async API documentation</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Flutter App Lifecycle Explained: What You Need to Know]]></title><description><![CDATA[Understanding your Flutter app's lifecycle is what separates an app that quietly loses data when the user switches apps from one that doesn't. This guide walks through the actual states — all five of ]]></description><link>https://gidudunicholas.dev/flutter-app-lifecycle-explained-what-you-need-to-know</link><guid isPermaLink="true">https://gidudunicholas.dev/flutter-app-lifecycle-explained-what-you-need-to-know</guid><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Thu, 19 Sep 2024 22:38:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1726785387216/b4716b60-84f3-45ac-96fd-1f48775ac263.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Understanding your Flutter app's lifecycle is what separates an app that quietly loses data when the user switches apps from one that doesn't. This guide walks through the actual states — all five of them, not the four still cited in most tutorials written before Flutter 3.13 — and shows each best practice wired into real code rather than just named.</p>
<h2>Overview: the five lifecycle states</h2>
<p>A Flutter app moves through a fixed set of states, driven by user actions (switching apps, taking a call) and system events (the OS reclaiming memory).</p>
<p><strong>Resumed</strong>: the app is in the foreground and interacting with the user — this is the only state where your UI is actually visible and receiving input.</p>
<p><strong>Inactive</strong>: the app is in the foreground but not receiving input. This is more common than it sounds — it fires during an incoming call, when the user pulls down the notification shade or opens the app switcher, and, critically, whenever a native permission dialog, a picker, or a system sheet is shown on top of your app. That last case is a real gotcha: your app hasn't actually gone anywhere, but it will still report <code>inactive</code>, so code that assumes "inactive means the user left" will misfire the moment you request camera or location permission.</p>
<p><strong>Hidden</strong>: the app is no longer visible to the user, but hasn't yet been fully backgrounded — this state was added in Flutter 3.13 and sits between <code>inactive</code> and <code>paused</code> in the normal backgrounding sequence. Most guides written before that release still describe only four states; if you're checking against the current <code>AppLifecycleState</code> enum, this one is real and worth handling explicitly rather than letting it silently fall into a default branch.</p>
<p><strong>Paused</strong>: the app is running in the background and not visible. This is where the OS may reclaim resources or, eventually, kill the process outright — it's the most important state for saving data you can't afford to lose.</p>
<p><strong>Detached</strong>: the Flutter engine is running without an attached view. This isn't exclusively a shutdown signal — it can also occur briefly during app startup, before the first view has attached, so code that assumes <code>detached</code> only ever fires once, at the very end of the app's life, can be surprised by it appearing near launch too. Treat it as "no view is currently attached to the engine," not strictly "the app is dying."</p>
<h2>Observing lifecycle changes</h2>
<p><code>WidgetsBindingObserver</code> is the standard mechanism, and the two calls that matter most are the ones bookending it — <code>addObserver</code> in <code>initState</code>, <code>removeObserver</code> in <code>dispose</code> — since skipping the second one leaks the observer the same way an unclosed <code>StreamSubscription</code> does:</p>
<pre><code class="language-dart">class _MyAppState extends State&lt;MyApp&gt; with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    super.didChangeAppLifecycleState(state);
    debugPrint('Lifecycle state: $state');
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Flutter App Lifecycle')),
        body: const Center(child: Text('App Lifecycle Demo')),
      ),
    );
  }
}
</code></pre>
<h2>A worked example: saving and restoring state correctly</h2>
<p>Here's the version that actually does something, rather than printing the state name — a screen with an in-progress form that needs to survive the user getting a phone call mid-edit:</p>
<pre><code class="language-dart">class _EditNoteScreenState extends State&lt;EditNoteScreen&gt; with WidgetsBindingObserver {
  final _textController = TextEditingController();
  Timer? _syncTimer;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _restoreDraft();
    _syncTimer = Timer.periodic(const Duration(seconds: 30), (_) =&gt; _syncToServer());
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _syncTimer?.cancel();
    _textController.dispose();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.inactive:
        // Foreground but interrupted — could be a permission dialog, could be
        // the start of backgrounding. Cheap to save here; too early to pause work.
        _saveDraftLocally();
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
        // Genuinely backgrounded now — stop anything that shouldn't run
        // while the user can't see it, and make sure the draft is safe.
        _saveDraftLocally();
        _syncTimer?.cancel();
      case AppLifecycleState.resumed:
        // Back in the foreground — resume periodic work and check for
        // anything that changed while we were away.
        _syncTimer = Timer.periodic(const Duration(seconds: 30), (_) =&gt; _syncToServer());
        _checkForRemoteChanges();
      case AppLifecycleState.detached:
        // No view attached — could be startup or shutdown. Save defensively
        // either way; there's no harm in an extra local save.
        _saveDraftLocally();
    }
  }

  void _saveDraftLocally() {
    localStorage.write('draft_${widget.noteId}', _textController.text);
  }

  Future&lt;void&gt; _restoreDraft() async {
    final saved = await localStorage.read('draft_${widget.noteId}');
    if (saved != null) _textController.text = saved;
  }

  Future&lt;void&gt; _syncToServer() async {
    await noteRepository.save(widget.noteId, _textController.text);
  }

  Future&lt;void&gt; _checkForRemoteChanges() async {
    final remote = await noteRepository.fetch(widget.noteId);
    if (remote.updatedAt.isAfter(widget.lastKnownUpdate)) {
      // Handle a conflict — someone else edited this note while we were away.
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Edit Note')),
      body: TextField(controller: _textController, maxLines: null),
    );
  }
}
</code></pre>
<p>Notice <code>inactive</code> and the backgrounded states (<code>hidden</code>/<code>paused</code>) are handled differently, on purpose: saving a draft is cheap and safe to do on every <code>inactive</code> transition, even the ones caused by a permission dialog that immediately returns to <code>resumed</code> — but tearing down the sync timer only happens once the app is genuinely backgrounded, since doing that on every <code>inactive</code> would mean a permission prompt momentarily and pointlessly kills a timer that's about to be needed again a second later.</p>
<h2>Best practices, made concrete</h2>
<p><strong>Save critical data during</strong> <code>inactive</code><strong>, not just</strong> <code>paused</code><strong>.</strong> Because <code>inactive</code> can be the last state you see before a hard kill on some platforms and situations, and it's cheap to write to local storage, saving early costs little and protects against interruptions that never reach <code>paused</code> at all.</p>
<p><strong>Pause expensive, user-visible work specifically at</strong> <code>hidden</code><strong>/</strong><code>paused</code><strong>, not</strong> <code>inactive</code><strong>.</strong> Animations, video playback, and timers should stop once the app is actually backgrounded — stopping them on every <code>inactive</code> transition means visibly pausing and restarting for every permission dialog or system sheet, which reads as a bug to the user even though it's technically correct lifecycle handling.</p>
<p><strong>Treat</strong> <code>detached</code> <strong>as "no view attached," and save defensively rather than assuming it's the last thing that will ever happen</strong> — since it can appear at startup too, code here should be idempotent and safe to run more than once.</p>
<p><strong>Resume and reconcile at</strong> <code>resumed</code>, not just restart what was paused — check whether the data on screen might now be stale, the way <code>_checkForRemoteChanges()</code> does above, since time passed while the app was backgrounded and the world may have moved on without it.</p>
<h2>Platform differences worth knowing</h2>
<p>Android apps are more likely to be killed outright while backgrounded, especially on memory-constrained devices — treat <code>paused</code> as "this might be the last code that runs" rather than assuming <code>detached</code> will reliably follow it. iOS's lifecycle is more predictable, but <code>inactive</code> fires more readily there — Face ID prompts, the app switcher, and Control Center all trigger it, which is exactly the permission-dialog gotcha above, more pronounced on iOS than Android.</p>
<h2>Common use cases</h2>
<p><strong>Push notifications</strong> can bring the app from <code>resumed</code> to <code>inactive</code> and back in quick succession as the notification banner is handled — this is a case where treating <code>inactive</code> as "pause everything" would cause visible, unnecessary flicker.</p>
<p><strong>Background tasks</strong> like location tracking or media playback need explicit handling at <code>paused</code>, since Dart code stops running once the app is fully backgrounded on most platforms — genuinely persistent background work needs a platform-specific mechanism (WorkManager on Android, background modes on iOS), not just a lifecycle callback that keeps a <code>Timer</code> running, since that timer will not fire once the app is suspended.</p>
<p><strong>State restoration</strong> is what the worked example above does end-to-end — save on interruption, restore on relaunch, and reconcile with the server on resume rather than trusting that nothing changed while the app was away.</p>
<h2>Conclusion</h2>
<p>The lifecycle states aren't just five labels to switch on — <code>inactive</code> and the backgrounded states solve genuinely different problems, and conflating them (pausing everything on <code>inactive</code>, or only saving data at <code>paused</code>) is where lifecycle bugs actually come from. Handle the five states for what they specifically mean, wire the handling into real save/resume logic rather than a print statement, and the app lifecycle stops being a source of silent data loss and starts being one of the more reliable parts of the app.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://api.flutter.dev/flutter/dart-ui/AppLifecycleState.html">AppLifecycleState — Flutter API documentation</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html">WidgetsBindingObserver — Flutter API documentation</a></p>
</li>
<li><p><a href="https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html">AppLifecycleListener — Flutter API documentation</a></p>
</li>
<li><p><a href="https://developer.android.com/topic/libraries/architecture/workmanager">Background processes on Android — WorkManager</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/release/release-notes">Flutter 3.13 release notes — AppLifecycleState.hidden</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Flutter Offline-First App Development: A Beginner's Guide]]></title><description><![CDATA[Say part of your app needs a live connection to work — a news feed, a product catalog, anything backed by a server. Offline-first means the app still shows the user something useful without a connecti]]></description><link>https://gidudunicholas.dev/flutter-offline-first-app-development-a-beginners-guide</link><guid isPermaLink="true">https://gidudunicholas.dev/flutter-offline-first-app-development-a-beginners-guide</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[offline first]]></category><category><![CDATA[offline first apps]]></category><category><![CDATA[newbie]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Wed, 19 Jun 2024 15:34:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1718811004428/6ee76bc8-85e2-48fd-8955-7bc4e5d27ad8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Say part of your app needs a live connection to work — a news feed, a product catalog, anything backed by a server. Offline-first means the app still shows the user something useful without a connection: the last data it successfully fetched, refreshed silently in the background the next time a connection is available.</p>
<p>This guide builds that pattern for real, with runnable code at every step — a news feed using a public API, cached locally, served from cache the moment the network fails rather than as an afterthought.</p>
<h2>The tools, and why each one</h2>
<p><strong>Dio</strong> for the network layer — it gives us interceptors and clean error typing, which matters once we need to distinguish "no connection" from "server error" reliably.</p>
<p><strong>Hive</strong> for caching the structured API response — not <code>flutter_cache_manager</code>, which is built and best known for caching <em>files</em> (particularly images) with automatic expiry, not structured JSON data. Hive is a fast, simple key-value store that's the more conventional choice for exactly this job: caching a list of articles as data, not as a file.</p>
<p><code>flutter_cache_manager</code> kept for the one thing it's genuinely built for in this app: caching the article thumbnail images, which <em>are</em> files.</p>
<p><code>connectivity_plus</code> to detect whether we're online before deciding which path to take.</p>
<p><strong>Provider</strong> for state management, tying the repository's output to the UI.</p>
<pre><code class="language-yaml">dependencies:
  dio: ^5.4.0
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  flutter_cache_manager: ^3.3.1
  connectivity_plus: ^6.0.0
  provider: ^6.1.1
</code></pre>
<h2>The article model</h2>
<pre><code class="language-dart">import 'package:hive/hive.dart';

part 'article.g.dart'; // generated by build_runner — see Hive's codegen docs

@HiveType(typeId: 0)
class Article {
  Article({required this.title, required this.description, required this.url, required this.imageUrl});

  @HiveField(0)
  final String title;
  @HiveField(1)
  final String description;
  @HiveField(2)
  final String url;
  @HiveField(3)
  final String imageUrl;

  factory Article.fromJson(Map&lt;String, dynamic&gt; json) =&gt; Article(
    title: json['title'] ?? '',
    description: json['description'] ?? '',
    url: json['url'] ?? '',
    imageUrl: json['urlToImage'] ?? '',
  );
}
</code></pre>
<p>The <code>@HiveType</code>/<code>@HiveField</code> annotations are what let Hive store this as a real typed object rather than a raw map — run <code>dart run build_runner build</code> after adding them to generate <code>article.g.dart</code>.</p>
<h2>Setting up Hive</h2>
<pre><code class="language-dart">Future&lt;void&gt; main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(ArticleAdapter());
  await Hive.openBox&lt;Article&gt;('articles');
  runApp(const MyApp());
}
</code></pre>
<h2>The repository: network-first, cache as the explicit fallback</h2>
<p>This is the part that actually makes the app offline-first rather than just "caches things as a side effect." The order matters: try the network, and only fall back to cache when it genuinely fails — not as an optimization to avoid network calls, but as the path that keeps the app usable when there's no connection at all.</p>
<pre><code class="language-dart">class NewsRepository {
  NewsRepository(this._dio, this._box);

  final Dio _dio;
  final Box&lt;Article&gt; _box;

  Future&lt;List&lt;Article&gt;&gt; getArticles() async {
    final connectivity = await Connectivity().checkConnectivity();
    final hasConnection = connectivity != ConnectivityResult.none;

    if (hasConnection) {
      try {
        final response = await _dio.get(
          '${ApiConstants.baseUrl}/top-headlines',
          queryParameters: {'country': 'us', 'apiKey': ApiConstants.apiKey},
        );
        final articles = (response.data['articles'] as List)
            .map((json) =&gt; Article.fromJson(json))
            .toList();

        await _box.clear();
        await _box.addAll(articles); // cache is refreshed only after a successful fetch

        return articles;
      } on DioException {
        // Network reported available but the request still failed —
        // treat it exactly like being offline rather than crashing.
        return _getCachedArticles();
      }
    }

    return _getCachedArticles();
  }

  List&lt;Article&gt; _getCachedArticles() =&gt; _box.values.toList();

  bool get hasCachedData =&gt; _box.isNotEmpty;
}
</code></pre>
<p>Two things worth calling out: <code>Connectivity().checkConnectivity()</code> reports whether a network interface exists, not whether the API call will actually succeed — which is why the <code>try/catch</code> around the <code>Dio</code> call exists as a second layer, catching the case where the phone thinks it's online but the request fails anyway (a weak signal, a captive portal, a server outage). And the cache is only overwritten <em>after</em> a successful fetch, never before — a failed request should never wipe out data that was working a moment ago.</p>
<h2>Surfacing staleness to the user</h2>
<p>The part offline-first guides skip most often: the UI should tell the user when they're looking at cached data, not just silently serve it as if it were fresh.</p>
<pre><code class="language-dart">class NewsController extends ChangeNotifier {
  NewsController(this._repository);
  final NewsRepository _repository;

  List&lt;Article&gt; articles = [];
  bool isOffline = false;
  bool isLoading = false;

  Future&lt;void&gt; loadArticles() async {
    isLoading = true;
    notifyListeners();

    final connectivity = await Connectivity().checkConnectivity();
    isOffline = connectivity == ConnectivityResult.none;

    articles = await _repository.getArticles();
    isLoading = false;
    notifyListeners();
  }
}
</code></pre>
<pre><code class="language-dart">class NewsScreen extends StatelessWidget {
  const NewsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer&lt;NewsController&gt;(
      builder: (context, controller, _) {
        if (controller.isLoading) return const Center(child: CircularProgressIndicator());

        return Column(
          children: [
            if (controller.isOffline)
              Container(
                color: Colors.amber.shade100,
                padding: const EdgeInsets.all(8),
                child: const Text('Offline — showing saved articles'),
              ),
            Expanded(
              child: ListView.builder(
                itemCount: controller.articles.length,
                itemBuilder: (context, i) {
                  final article = controller.articles[i];
                  return ListTile(
                    leading: CachedNetworkImage(
                      imageUrl: article.imageUrl,
                      cacheManager: DefaultCacheManager(), // flutter_cache_manager, for the image file specifically
                      errorWidget: (context, url, error) =&gt; const Icon(Icons.image_not_supported),
                    ),
                    title: Text(article.title),
                    subtitle: Text(article.description),
                  );
                },
              ),
            ),
          ],
        );
      },
    );
  }
}
</code></pre>
<p>That amber banner is a small addition with an outsized effect on trust — a user who sees three-hour-old headlines with no indication they're stale will assume the app is broken or slow; the same user shown "Offline — showing saved articles" understands exactly what's happening and why.</p>
<h2>Refreshing silently when connectivity returns</h2>
<p>Listen for the transition back online and refresh automatically, rather than waiting for the user to pull-to-refresh into a connection that's already back:</p>
<pre><code class="language-dart">class NewsController extends ChangeNotifier {
  // ...previous fields...
  StreamSubscription&lt;List&lt;ConnectivityResult&gt;&gt;? _connectivitySub;

  NewsController(this._repository) {
    _connectivitySub = Connectivity().onConnectivityChanged.listen((results) {
      if (isOffline &amp;&amp; !results.contains(ConnectivityResult.none)) {
        loadArticles(); // connection just came back — refresh quietly
      }
    });
  }

  void dispose() {
    _connectivitySub?.cancel();
    super.dispose();
  }
}
</code></pre>
<h2>Cache invalidation: the part most offline-first tutorials skip entirely</h2>
<p>Serving cached data indefinitely without any expiry means a user who hasn't opened the app in three weeks sees three-week-old headlines with no signal that anything is wrong. Store a timestamp alongside the cache and use it to decide whether to show a staleness warning even while online-but-serving-cache in edge cases, or to force a background refresh past a certain age:</p>
<pre><code class="language-dart">Future&lt;void&gt; _cacheArticles(List&lt;Article&gt; articles) async {
  await _box.clear();
  await _box.addAll(articles);
  await Hive.box('meta').put('articles_cached_at', DateTime.now().toIso8601String());
}

bool get isCacheStale {
  final cachedAtStr = Hive.box('meta').get('articles_cached_at') as String?;
  if (cachedAtStr == null) return true;
  final cachedAt = DateTime.parse(cachedAtStr);
  return DateTime.now().difference(cachedAt) &gt; const Duration(hours: 6);
}
</code></pre>
<p>Six hours is an arbitrary starting point — the right threshold depends entirely on how time-sensitive the data is; a news feed and a product catalog have very different reasonable staleness windows.</p>
<h2>Conclusion</h2>
<p>Offline-first isn't "cache things for performance and hope it also helps offline" — it's a specific architecture where the fallback to cache is an explicit branch triggered by a failed or absent connection, the cache is only ever refreshed after a successful fetch, staleness is visible to the user rather than silent, and reconnection triggers a refresh without the user having to ask for one. None of that requires complex tooling — Hive for the data, <code>flutter_cache_manager</code> for the images it's actually built for, and <code>connectivity_plus</code> to know which path to take — but it does require treating "offline" as a real, designed-for state rather than an edge case the caching happens to soften.</p>
<p>A full working example combining all of the above is available at <a href="https://github.com/Nicopee/News-App">github.com/Nicopee/News-App</a>.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://pub.dev/packages/hive">Hive — pub.dev</a></p>
</li>
<li><p><a href="https://pub.dev/packages/connectivity_plus">connectivity_plus — pub.dev</a></p>
</li>
<li><p><a href="https://pub.dev/packages/flutter_cache_manager">flutter_cache_manager — pub.dev</a></p>
</li>
<li><p><a href="https://pub.dev/packages/dio">Dio — pub.dev</a></p>
</li>
<li><p><a href="https://docs.flutter.dev/app-architecture/design-patterns/offline-first">Flutter: offline-first patterns</a></p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Experience Seamless Production Updates with Shorebird in Flutter]]></title><description><![CDATA[A note before diving in: Shorebird is a live, actively-developing SaaS product, and its CLI commands, pricing, and account flow can change between releases. The commands below reflect the general work]]></description><link>https://gidudunicholas.dev/experience-seamless-production-updates-with-shorebird-in-flutter</link><guid isPermaLink="true">https://gidudunicholas.dev/experience-seamless-production-updates-with-shorebird-in-flutter</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[#shorebird]]></category><category><![CDATA[technology]]></category><category><![CDATA[#codenewbies]]></category><dc:creator><![CDATA[Gidudu Nicholas]]></dc:creator><pubDate>Mon, 10 Jun 2024 09:31:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1718012477539/1936cc84-b086-4f8f-be9e-185c6271df4f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>A note before diving in: Shorebird is a live, actively-developing SaaS product, and its CLI commands, pricing, and account flow can change between releases. The commands below reflect the general workflow, but verify the exact syntax against <a href="https://docs.shorebird.dev">docs.shorebird.dev</a> before running anything in a real project — this is the one area of this guide most likely to drift out of date.</p>
</blockquote>
<p>Publishing a one-line text fix to the App Store or Play Store means a full review cycle — sometimes hours, sometimes days, sometimes a rejection that sends you back to the start. Shorebird is a code-push service built specifically for Flutter that lets you patch your app's Dart code in production without going through that review cycle at all, for the category of change it can actually make.</p>
<h2>What Shorebird can and can't do — read this before anything else</h2>
<p>This is the single most important thing to understand before adopting Shorebird, and it's usually left for the end of a tutorial or skipped entirely: <strong>Shorebird can only patch Dart code.</strong> It cannot ship a new native dependency, a new permission, a new app icon, a change to <code>Info.plist</code> or <code>AndroidManifest.xml</code>, or anything that touches the native shell of the app. Fixing a typo in a widget, correcting broken business logic, or adjusting a Dart-level bug is exactly what it's for. Adding a new plugin that touches native code, or changing anything Apple/Google review actually cares about, still requires a normal store release.</p>
<p>This works at all because Shorebird ships a forked version of the Flutter engine with a Dart interpreter built in — patches are interpreted at runtime rather than compiled ahead-of-time like the rest of your app, which is also why development against a Shorebird-enabled app uses <code>shorebird run</code> instead of <code>flutter run --release</code>: it's running against that modified engine, not stock Flutter.</p>
<h2>Setting up</h2>
<p><strong>1. Install the CLI:</strong></p>
<pre><code class="language-bash"># macOS/Linux
curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash

# Windows (PowerShell)
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
iwr 'https://raw.githubusercontent.com/shorebirdtech/install/main/install.ps1' | iex
</code></pre>
<p><strong>2. Create an account and log in.</strong> Shorebird requires a Shorebird account before you can push a release — sign up at their site, then:</p>
<pre><code class="language-bash">shorebird login
</code></pre>
<p>Worth knowing up front: Shorebird's pricing is based on patch installs per month, with a free tier for smaller apps and paid tiers beyond it. Check their current pricing page before committing a production app to it, since this is exactly the kind of detail that changes as the product matures.</p>
<p><strong>3. Initialize your project:</strong></p>
<pre><code class="language-bash">shorebird init
</code></pre>
<p>This generates a <code>shorebird.yaml</code> file containing a unique <code>app_id</code> that identifies your app to Shorebird's backend for every future release and patch:</p>
<pre><code class="language-yaml">app_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
</code></pre>
<h2>Creating a release</h2>
<p>A release is the baseline version that future patches will apply against — you need one release published before you can push any patches to it:</p>
<pre><code class="language-bash">shorebird release android
shorebird release ios
</code></pre>
<p>By default this produces a production-ready app bundle (<code>.aab</code> on Android). Pin the Flutter version explicitly if your project depends on a specific one:</p>
<pre><code class="language-bash">shorebird release android --flutter-version=3.24.0
</code></pre>
<p>This release still needs to go through the normal App Store / Play Store submission process once — Shorebird doesn't skip that first step; it's what makes every <em>subsequent</em> Dart-only fix skip it.</p>
<h2>Previewing before you ship</h2>
<pre><code class="language-bash">shorebird preview
</code></pre>
<p>This installs the release build on a connected device or emulator so you can verify it behaves as expected before real users see it — a sanity check worth doing every time, since a release is the thing every future patch is diffed against.</p>
<h2>Pushing a patch</h2>
<p>Once a release is live, ship a Dart-only fix without a new store submission:</p>
<pre><code class="language-bash">shorebird patch android
shorebird patch ios
</code></pre>
<p>Under the hood, this command:</p>
<ol>
<li><p>Builds the updated Dart artifacts from your current code.</p>
</li>
<li><p>Downloads the corresponding release artifacts to diff against.</p>
</li>
<li><p>Computes a binary patch — the difference between the release and your current code, not the whole app.</p>
</li>
<li><p>Uploads that patch to Shorebird's backend.</p>
</li>
<li><p>Promotes the patch to the stable channel, making it available to install.</p>
</li>
</ol>
<h2>When users actually see the update</h2>
<p>This is worth stating plainly, since "seamless" and "instant" get used loosely in code-push marketing generally: a pushed patch does not apply to an app that's currently running. Users see the update the <em>next time they fully restart the app</em> — not mid-session, and not via a hot-reload-style live update while they're actively using it. "Seamless" here means "no store review and no manual update prompt," not "changes appear while the app is open." If your use case specifically requires a change to take effect without any restart at all, code push isn't the right tool for that regardless of provider.</p>
<h2>A realistic use case</h2>
<p>The scenario code push is built for: you ship a release, and within a day discover a typo in an error message, a miscalculated discount percentage, or a broken conditional in a Dart file — nothing native, nothing requiring a new permission. Instead of a new build working through review for days, <code>shorebird patch</code> gets the fix in front of users on their next app open, typically within minutes of the patch being promoted.</p>
<p>What it's not built for: a UI change requiring a new native plugin, an SDK upgrade for a native library, or anything Apple or Google's review process is specifically checking for — those still need a full release cycle, and no code-push tool changes that.</p>
<h2>Conclusion</h2>
<p>Shorebird fills a real, long-standing gap in Flutter's release story — the ability to fix a Dart-only bug without a multi-day review cycle standing between you and your users. Its value is genuinely large for the specific category of fix it addresses, and correspondingly limited outside that category: it's not a general bypass of app store review, it doesn't apply changes mid-session, and it requires the same forked-engine <code>shorebird run</code>/<code>shorebird release</code> workflow instead of the stock Flutter commands you're used to. Understood with those boundaries in mind, it's one of the more genuinely useful additions to the Flutter ecosystem in recent years — just verify the exact current commands and pricing against Shorebird's own docs, since a tool this young moves faster than any blog post about it can keep up with.</p>
<hr />
<p><strong>References</strong></p>
<ol>
<li><p><a href="https://docs.shorebird.dev">Shorebird documentation</a></p>
</li>
<li><p><a href="https://github.com/shorebirdtech/shorebird">Shorebird GitHub repository</a></p>
</li>
<li><p><a href="https://shorebird.dev/pricing">Shorebird pricing</a></p>
</li>
<li><p><a href="https://docs.shorebird.dev/faq">How code push works — Shorebird's engine fork</a></p>
</li>
</ol>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1718011662334/bd66dbe3-0ba2-4679-adf4-0a85079c3d23.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item></channel></rss>