<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged dart</title>
<link>https://coderlegion.com/tag/dart</link>
<description>Powered by Question2Answer</description>
<item>
<title>flutter_skin Alpha — Material 3 Tokens, Live Preview &amp; Typography | Remote Skin</title>
<link>https://coderlegion.com/24483/flutterskin-alpha-material-3-tokens-live-preview-typography-remote-skin</link>
<description>&lt;p&gt;Alpha V1 is live.&lt;/p&gt;
&lt;p&gt;→ Full Material 3 color token support&lt;br&gt;
→ Live skin preview directly in the dashboard&lt;br&gt;
→ Typography tokens — change your app's font remotely&lt;/p&gt;
&lt;p&gt;No rebuild. No App Store update. Publish from the&lt;br&gt;
dashboard, app updates live.&lt;/p&gt;
&lt;p&gt;─────────────────────────────&lt;/p&gt;
&lt;p&gt;🔗 Links&lt;/p&gt;
&lt;p&gt;📦 Package: pub.dev/packages/flutter_skin&lt;br&gt;
🖥️ Dashboard: app.fskin.dev&lt;br&gt;
📖 Docs: docs.fskin.dev&lt;/p&gt;
&lt;p&gt;─────────────────────────────&lt;/p&gt;
&lt;p&gt;What's coming in Beta:&lt;br&gt;
→ Spacing tokens&lt;br&gt;
→ Border radius tokens&lt;/p&gt;
&lt;p&gt;→ A/B testing&lt;br&gt;
→ MCP server for AI-generated skins&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/24483/flutterskin-alpha-material-3-tokens-live-preview-typography-remote-skin</guid>
<pubDate>Wed, 12 Aug 2026 08:36:49 +0000</pubDate>
</item>
<item>
<title>flutter_skin — Live Remote Skin Engine Demo | Change Flutter Colors Without Rebu</title>
<link>https://coderlegion.com/21977/flutterskin-live-remote-skin-engine-demo-change-flutter-colors-without-rebu</link>
<description>&lt;p&gt;Live Remote Skin Engine Demo | Change Flutter Colors Without Rebuilding.&lt;br&gt;
This is the first demo of flutter_skin — a remote skin engine &lt;br&gt;
for Flutter I've been building for the past few months.&lt;/p&gt;
&lt;p&gt;This is the alpha. Color tokens only for now. Typography, &lt;br&gt;
spacing, border radii, and A/B testing are coming.&lt;/p&gt;
&lt;p&gt;Built by Badr Kouki&lt;br&gt;
Flutter contributor | pub.dev publisher&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/21977/flutterskin-live-remote-skin-engine-demo-change-flutter-colors-without-rebu</guid>
<pubDate>Mon, 06 Jul 2026 06:20:40 +0000</pubDate>
</item>
<item>
<title>Reflection in Dart</title>
<link>https://coderlegion.com/21557/reflection-in-dart</link>
<description>&lt;p&gt;I have just published an article on using &lt;code&gt;dart:mirrors&lt;/code&gt; for reflection and dynamic invocation in Dart. Many languages support reflection, and Dart is among them. The article introduces the package—which provides reflection support in Dart—examines its limitations, and presents both experimental and practical examples.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@Victorldev/reflection-in-dart-c98248a7a4ea&quot;&gt;Checkout&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21557/reflection-in-dart</guid>
<pubDate>Mon, 29 Jun 2026 09:39:48 +0000</pubDate>
</item>
<item>
<title>Stop Guessing Frame Drops: Passive Zero Overhead Performance Telemetry for Flutter</title>
<link>https://coderlegion.com/19709/stop-guessing-frame-drops-passive-zero-overhead-performance-telemetry-for-flutter</link>
<description>&lt;p&gt;&lt;strong&gt;Automatically capture frame drops (jank) and isolate rendering bottlenecks on the UI and Raster threads directly in your dev console&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Every Flutter developer knows the pain of subtle micro-stuttering&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A slight visual hiccup when navigating routes.&lt;/li&gt;
&lt;li&gt;Scroll stutters on content-heavy feed lists.&lt;/li&gt;
&lt;li&gt;Sluggish transitions when pulling up custom sheets.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We call this jank when the Flutter rendering pipeline fails to finish producing a frame within the device's hardware refresh budget (typically 16.67ms for 60Hz or 8.33ms for high refresh 120Hz screens). But actively hunting down these frame drops is tedious. You're forced to keep a browser tab open with Flutter DevTools, maintain active Socket connections, or install heavy analytics SDKs that bloat your production bundle.&lt;/p&gt;
&lt;p&gt;That's why I built jank_scout. It passively monitors rendering lifecycle timings, analyzes thread bottlenecks, and outputs clean diagnostics directly to your terminal. Best of all, it completely tree-shakes itself out of your production release.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What It Looks Like in Practice&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When a rendering timing budget is broken, jank_scout automatically identifies whether the freeze occurred on the UI Thread (CPU) or the Raster Thread (GPU) and displays a diagnostic ASCII report in your console&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;+----------------------------------------------------------------------+
| TELEMETRY REPORT:  [PIPELINE CRITICAL INTERRUPT]
+----------------------------------------------------------------------+
| Target Route: /details
| Budget: 16.67 ms (Target FPS: 60)
| Frame Render Time: 76.50 ms (Overrun: 358.9%, +59.83 ms)
| Thread Breakdowns:
|   - UI Thread (CPU Build):  68.20 ms
|   - Raster Thread (GPU):   8.30 ms
+----------------------------------------------------------------------+
| Bottleneck Analysis:
| BOTTLENECK: UI Thread (CPU Boundary). Diagnostic: Excessive execution cycle detected on the Dart isolate runtime loop. Remediate by auditing synchronous serialization, unoptimized layout passes, or high-frequency state emissions violating state boundary conditions.
+----------------------------------------------------------------------+

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The Architecture Under the Hood&lt;/strong&gt;&lt;br&gt;
How does it hook into the engine with zero dependencies?&lt;/p&gt;
&lt;p&gt;It utilizes Flutter's native SchedulerBinding callback pipeline to receive raw frame timelines asynchronously&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;SchedulerBinding.instance.addTimingsCallback((List&amp;lt;FrameTiming&amp;gt; timings) {
  // Pass timing metrics into the monitor engine
  JankScout.processTimings(timings);
});

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;jank_scout breaks down each frame into&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;UI Thread Time: widget tree creation, layout cycles, and drawing calls.&lt;/li&gt;
&lt;li&gt;Raster Thread Time: GPU execution translating the layer tree into pixels.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Safe Production Tree-Shaking&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To ensure that the package adds exactly zero bytes and zero background overhead in your production app, jank_scout wraps its entire registration and logging cycles inside assert boundaries and compiler optimizations&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;assert(() {
  // This code is completely stripped out during `flutter build --release`
  _initializeFrameMonitor();
  return true;
}());

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Real-World Case Study: The Synchronous JSON List Freeze&lt;/strong&gt;&lt;br&gt;
Let's look at a classic local performance bottleneck parsing a massive JSON payload inside your UI widget builds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;X The Bad Code (Stalls UI Thread)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class FeedListScreen extends StatelessWidget {
  final String rawJsonString; // Large JSON string from network or local cache

  const FeedListScreen({super.key, required this.rawJsonString});

  @override
  Widget build(BuildContext context) {
    // ❌ CRITICAL JANK: Blocks Dart's main single-threaded event loop
    final List&amp;lt;dynamic&amp;gt; decodedData = jsonDecode(rawJsonString);
    final List&amp;lt;FeedItem&amp;gt; items = decodedData.map((e) =&amp;gt; FeedItem.fromJson(e)).toList();

    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) =&amp;gt; FeedItemCard(item: items[index]),
    );
  }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since Dart is single-threaded, running jsonDecode synchronously forces the UI thread to freeze while processing. The user experiences a frame drop. jank_scout immediately catches this and flags the UI Thread (CPU Boundary) as the culprit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Clean Fix (Background Concurrency)&lt;/strong&gt;&lt;br&gt;
To restore a butter-smooth 60 or 120 FPS scrolling list, offload the processing to a background thread using Dart isolates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; Spawning a Temporary Isolate using compute()&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;List&amp;lt;FeedItem&amp;gt;&amp;gt; loadFeedAsync(String rawJson) async {
  // Offloads JSON processing to a temporary worker isolate
  return await compute(_parseJsonFeed, rawJson);
}

// Global or top-level function
List&amp;lt;FeedItem&amp;gt; _parseJsonFeed(String rawJson) {
  final List&amp;lt;dynamic&amp;gt; decoded = jsonDecode(rawJson);
  return decoded.map((e) =&amp;gt; FeedItem.fromJson(e)).toList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt;Persistent Isolate using Isolate.spawn()&lt;br&gt;
For continuous large payloads, keep a dedicated worker isolate alive to save isolate initialization latency&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;List&amp;lt;FeedItem&amp;gt;&amp;gt; parseWithManualIsolate(String rawJson) async {
  final receivePort = ReceivePort();
  
  final isolate = await Isolate.spawn(
    _isolateWorkerEntryPoint,
    _IsolatePayload(rawJson, receivePort.sendPort),
  );

  final List&amp;lt;FeedItem&amp;gt; items = await receivePort.first as List&amp;lt;FeedItem&amp;gt;;

  isolate.kill(priority: Isolate.beforeNextEvent);
  receivePort.close();

  return items;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By moving CPU intensive operations off the main isolate, scrolling stays fluid, the event loop remains clear, and jank_scout console output remains silent confirming optimal performance&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get Started in 3 Steps&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;1. Add dependency&lt;/strong&gt;&lt;br&gt;
Add the dependency to your pubspec.yaml&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  jank_scout: ^0.0.3

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Initialize in main.dart&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void main() {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Set your target device FPS
  JankScout.initialize(targetFps: 60.0);
  
  runApp(const MyApp());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. Add Router Route Tracking&lt;/strong&gt;&lt;br&gt;
Add the navigation observer in your MaterialApp to associate performance degradation reports with specific screens&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;MaterialApp(
  navigatorObservers: [JankScoutObserver()],
  initialRoute: '/',
  routes: {
    '/': (context) =&amp;gt; const HomeScreen(),
    '/feed': (context) =&amp;gt; const FeedListScreen(),
  },
);

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Try It Out!&lt;br&gt;
Let's make local Flutter development smoother together!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pub.dev:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/jank_scout&quot;&gt;junk_scout&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/umarKhan1/jank_scout&quot;&gt;Github Url&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;momarkhan.com&quot;&gt;My Portfolio&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you find jank_scout helpful, drop a ⭐ on GitHub and a like on pub.dev! Have any questions or feature ideas? Let's discuss in the comments below!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19709/stop-guessing-frame-drops-passive-zero-overhead-performance-telemetry-for-flutter</guid>
<pubDate>Fri, 05 Jun 2026 04:07:58 +0000</pubDate>
</item>
<item>
<title>The Quiet Work Behind a Flutter Endpoint</title>
<link>https://coderlegion.com/17770/the-quiet-work-behind-a-flutter-endpoint</link>
<description>&lt;p&gt;In a real app, an endpoint is rarely just an endpoint. It is a tiny agreement between the backend, the network client, the request model, the repository, the use case, the UI state, and sometimes one very tired developer trying to remember whether this response returns JSON, bytes, a list, or a &lt;code&gt;200&lt;/code&gt; with vibes.&lt;/p&gt;
&lt;p&gt;This is the part of my codebase I keep coming back to: the path from repository to service locator. It is not the flashiest part of the app, but it decides whether new features feel smooth or whether every new API integration becomes a small argument with myself.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-i-keep-repeating&quot;&gt;The shape I keep repeating&lt;/h3&gt;
&lt;p&gt;The project I worked on today has a fairly familiar Flutter structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;feature folders&lt;/li&gt;
&lt;li&gt;data sources for remote calls&lt;/li&gt;
&lt;li&gt;repositories for parsing and decision-making&lt;/li&gt;
&lt;li&gt;domain use cases&lt;/li&gt;
&lt;li&gt;a shared &lt;code&gt;DioClient&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GetIt&lt;/code&gt; as the service locator&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern is not exotic, but it has saved me more times than I expected.&lt;/p&gt;
&lt;p&gt;When I add a new endpoint, I usually move through the same mental chain:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add the URL constant.&lt;/li&gt;
&lt;li&gt;Add the method to the API service contract.&lt;/li&gt;
&lt;li&gt;Implement the Dio call.&lt;/li&gt;
&lt;li&gt;Expose it through the repository contract.&lt;/li&gt;
&lt;li&gt;Parse the response in the repository implementation.&lt;/li&gt;
&lt;li&gt;Wrap it in a use case.&lt;/li&gt;
&lt;li&gt;Register the use case, repository, and API service in &lt;code&gt;sl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Finally, call it from the UI or bloc/cubit layer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That sounds like a lot when written out. In practice, it gives each layer one job. The API service talks HTTP. The repository turns raw responses into app-friendly objects. The use case gives the UI a clean entry point. The service locator makes the whole thing reachable without manually wiring constructors everywhere.&lt;/p&gt;
&lt;p&gt;It is boring in a good way. And in mobile apps, &quot;boring in a good way&quot; is a compliment.&lt;/p&gt;
&lt;h3 id=&quot;where-things-got-slightly-less-boring&quot;&gt;Where things got slightly less boring&lt;/h3&gt;
&lt;p&gt;The endpoints I worked around were not all the same shape.&lt;/p&gt;
&lt;p&gt;Some were plain JSON requests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final response = await sl&amp;lt;DioClient&amp;gt;().post(
  ApiUrls.createBooking,
  data: requestBody.toJson(),
  options: Options(
    headers: {'Authorization': 'Bearer $token'},
  ),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some needed query parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final response = await sl&amp;lt;DioClient&amp;gt;().get(
  ApiUrls.getBookings,
  queryParameters: {
    'page': page,
    if (startDate?.trim().isNotEmpty == true) 'startDate': startDate,
    if (endDate?.trim().isNotEmpty == true) 'endDate': endDate,
  },
  options: Options(
    headers: {'Authorization': 'Bearer $token'},
  ),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some involved multipart uploads, which always make the room a little warmer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final formData = FormData();

for (final entry in requestBody.toFormFields().entries) {
  formData.fields.add(MapEntry(entry.key, entry.value));
}

if (imagePath != null &amp;amp;&amp;amp; imagePath.trim().isNotEmpty) {
  formData.files.add(
    MapEntry(
      'image',
      await MultipartFile.fromFile(
        imagePath,
        filename: imagePath.split(RegExp(r'[\\/]')).last,
      ),
    ),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then there were the document/image endpoints, where the response is not a neat JSON object at all. For those, the Dio options matter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final response = await sl&amp;lt;DioClient&amp;gt;().get(
  ApiUrls.downloadDocument(documentId),
  options: Options(
    headers: {
      'Authorization': 'Bearer $token',
      'Accept': '*/*',
    },
    responseType: ResponseType.bytes,
  ),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That small &lt;code&gt;ResponseType.bytes&lt;/code&gt; line is the kind of thing you forget once, then remember forever.&lt;/p&gt;
&lt;h3 id=&quot;why-i-do-not-let-the-ui-talk-directly-to-dio&quot;&gt;Why I do not let the UI talk directly to Dio&lt;/h3&gt;
&lt;p&gt;There was a time when I would have been tempted to call Dio directly inside a screen, especially if the endpoint looked small.&lt;/p&gt;
&lt;p&gt;Today-me does not trust that version of me.&lt;/p&gt;
&lt;p&gt;The screen should not care if the endpoint is &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, multipart, or returning bytes. It should not know whether the backend sends a list directly or wraps it inside a &lt;code&gt;data&lt;/code&gt; key. It should not know that one endpoint needs timezone as a query parameter while another one needs a generated ID in the URL.&lt;/p&gt;
&lt;p&gt;So the API service stays close to HTTP:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;abstract class VendorApiService {
  Future&amp;lt;Either&amp;gt; getTransactions({int page = 1});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the repository handles the translation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class VendorRepositoryImpl extends VendorRepository {
  @override
  Future&amp;lt;Either&amp;gt; getTransactions({int page = 1}) async {
    final result = await sl&amp;lt;VendorApiService&amp;gt;().getTransactions(page: page);

    return result.fold(
      (error) =&amp;gt; Left(error),
      (data) async {
        try {
          final response = data as Response;
          final transactions = TransactionsResponse.fromJson(
            (response.data as Map?)?.cast&amp;lt;String, dynamic&amp;gt;() ??
                const &amp;lt;String, dynamic&amp;gt;{},
          );
          return Right(transactions);
        } catch (e) {
          return Left('Failed to parse transactions.');
        }
      },
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That separation pays off when something changes. If the backend adjusts a response shape, I usually fix the repository. If auth headers are wrong, I look at the API service or client. If the UI state is confused, I inspect the use case call and state handling.&lt;/p&gt;
&lt;p&gt;It gives the bug a neighborhood.&lt;/p&gt;
&lt;h3 id=&quot;the-service-locator-is-where-i-check-my-work&quot;&gt;The service locator is where I check my work&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;GetIt&lt;/code&gt; is not just a dependency bucket for me. It is also the final checklist.&lt;/p&gt;
&lt;p&gt;When I register a new endpoint flow, I have to ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Did I register the API service?&lt;/li&gt;
&lt;li&gt;Did I bind the repository interface to its implementation?&lt;/li&gt;
&lt;li&gt;Did I add the new use case?&lt;/li&gt;
&lt;li&gt;Am I using the same feature boundary as the rest of the app?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The registrations usually look simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final sl = GetIt.instance;

void setupServiceLocator() {
  sl.registerLazySingleton&amp;lt;VendorApiService&amp;gt;(
    () =&amp;gt; VendorApiServiceImpl(),
  );

  sl.registerLazySingleton&amp;lt;VendorRepository&amp;gt;(
    () =&amp;gt; VendorRepositoryImpl(),
  );

  sl.registerLazySingleton&amp;lt;GetVendorTransactionsUseCase&amp;gt;(
    () =&amp;gt; GetVendorTransactionsUseCase(),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But that simplicity is exactly why I like it. When a use case fails at runtime because it was never registered, the error is loud and direct. Annoying, yes, but honest.&lt;/p&gt;
&lt;p&gt;The more dangerous bugs are the quiet ones: a method exists in the API service but not the repository, a repository parses the wrong model, or a use case accepts the wrong params and the UI starts passing half-correct data.&lt;/p&gt;
&lt;p&gt;That is why I try to move through the chain in order. URL. Service. Repository. Use case. Service locator. UI. If I jump around too much, I eventually pay for it.&lt;/p&gt;
&lt;h3 id=&quot;one-small-detail-that-mattered-more-than-expected&quot;&gt;One small detail that mattered more than expected&lt;/h3&gt;
&lt;p&gt;A pattern I appreciated today was keeping request bodies responsible for serialization.&lt;/p&gt;
&lt;p&gt;The endpoint method should not be manually assembling random maps everywhere if the request already has a model. This:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;data: requestBody.toJson(),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is much easier to trust than scattering field names across multiple screens.&lt;/p&gt;
&lt;p&gt;For multipart requests, I still have to be more hands-on because files and repeated fields need special handling. But even there, I prefer pushing normal text fields through something like &lt;code&gt;toFormFields()&lt;/code&gt; and letting the service only deal with the multipart-specific parts.&lt;/p&gt;
&lt;p&gt;It keeps the code from slowly becoming a collection of tiny string mistakes.&lt;/p&gt;
&lt;p&gt;And yes, a single wrong field name can waste thirty minutes. Sometimes more. I am not proud, I am just reporting from the field.&lt;/p&gt;
&lt;h3 id=&quot;the-endpoint-constants-are-not-just-strings&quot;&gt;The endpoint constants are not just strings&lt;/h3&gt;
&lt;p&gt;The app keeps endpoint paths centralized behind an &lt;code&gt;ApiUrls&lt;/code&gt; style class. The real values come from environment configuration, but the usage stays clean:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ApiUrls {
  static String get baseUrl =&amp;gt; 'https://api.example.com/';

  static final createBooking = '${baseUrl}client/create-booking';

  static String bookingReceipt(String bookingId) {
    return '${baseUrl}payment/$bookingId/booking';
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I like this because dynamic endpoints become explicit functions instead of random interpolation inside service methods.&lt;/p&gt;
&lt;p&gt;The tradeoff is that the constants file can grow large if I am not careful. Once an app has auth, client flows, vendor flows, analytics, notifications, payments, documents, chat, and logging, the endpoint list starts looking like a city map.&lt;/p&gt;
&lt;p&gt;Still, I prefer one crowded map to endpoint strings hiding in twenty different files.&lt;/p&gt;
&lt;h3 id=&quot;what-i-noticed-while-wiring-everything&quot;&gt;What I noticed while wiring everything&lt;/h3&gt;
&lt;p&gt;The endpoint layer quietly reveals the personality of the app.&lt;/p&gt;
&lt;p&gt;In this project, some features are simple CRUD. Others are more workflow-heavy: bookings, contracts, payouts, profile updates, analytics, reviews, notifications, documents, and media. That means the network layer cannot assume every response is the same kind of JSON.&lt;/p&gt;
&lt;p&gt;A few decisions made the code easier to reason about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;keeping auth token reads inside the API service layer&lt;/li&gt;
&lt;li&gt;returning &lt;code&gt;Either&lt;/code&gt; so failures stay explicit&lt;/li&gt;
&lt;li&gt;parsing inside repositories instead of UI code&lt;/li&gt;
&lt;li&gt;using use cases as the UI-facing API&lt;/li&gt;
&lt;li&gt;making file downloads return bytes instead of pretending they are normal models&lt;/li&gt;
&lt;li&gt;registering dependencies in one place so missing pieces are easier to spot&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also liked the small caching choice around relatively stable data. Not every endpoint deserves a network call every time. For data that rarely changes, a short-lived cache can remove friction without turning the app into a stale-data museum.&lt;/p&gt;
&lt;p&gt;That is the kind of decision that is easy to skip when you are just trying to &quot;make the endpoint work.&quot; But those little choices are where the app starts to feel more mature.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-keep-learning&quot;&gt;The part I keep learning&lt;/h3&gt;
&lt;p&gt;Writing endpoints is not hard because of the syntax.&lt;/p&gt;
&lt;p&gt;It is hard because each endpoint carries assumptions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does it need auth?&lt;/li&gt;
&lt;li&gt;Is the body JSON or multipart?&lt;/li&gt;
&lt;li&gt;Does it return a map, list, file, or empty success?&lt;/li&gt;
&lt;li&gt;Are IDs in the path or query params?&lt;/li&gt;
&lt;li&gt;Should the repository parse it or pass through the raw response?&lt;/li&gt;
&lt;li&gt;Does the UI need a typed model, bytes, or just success/failure?&lt;/li&gt;
&lt;li&gt;Should this use case take a params object instead of three loose strings?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these questions are dramatic. But they are the difference between a codebase that stays calm and one that becomes stressful to touch.&lt;/p&gt;
&lt;p&gt;By the end of the day, the win was not only that endpoints were wired. The real win was that the flow still made sense after more endpoints were added.&lt;/p&gt;
&lt;p&gt;That matters.&lt;/p&gt;
&lt;p&gt;Because tomorrow another endpoint will arrive with one weird query parameter, one optional file, one backend response that does not look like the others, and one screen waiting for a clean result.&lt;/p&gt;
&lt;p&gt;And when it does, I would rather follow a familiar path than negotiate with chaos again.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17770/the-quiet-work-behind-a-flutter-endpoint</guid>
<pubDate>Wed, 13 May 2026 21:22:11 +0000</pubDate>
</item>
<item>
<title>The Day White Became a Bug</title>
<link>https://coderlegion.com/17769/the-day-white-became-a-bug</link>
<description>&lt;p&gt;Today started with one of those tasks that looks almost too small to deserve a branch: prepare the Flutter app for dark mode by making sure colors are centralized.&lt;/p&gt;
&lt;p&gt;Simple enough, right?&lt;/p&gt;
&lt;p&gt;Find the hardcoded colors. Move them into AppColors class. Replace the literals. Run analyzer. Go eat something.&lt;/p&gt;
&lt;p&gt;Flutter, naturally, had a few notes.&lt;/p&gt;
&lt;p&gt;The first scan told the real story. There were direct Colors.white calls, direct Colors.black, Colors.grey[600], Colors.transparent, and a handful of Color(0x...) values hiding in screens, cards, bottom sheets, painters, buttons, notification badges, and little UI edges that probably nobody thinks about until dark mode makes them painfully visible.&lt;/p&gt;
&lt;p&gt;And that is the thing about dark mode: it punishes casual color decisions.&lt;/p&gt;
&lt;p&gt;A hardcoded Colors.white is harmless in a light theme. It feels obvious. Of course the card background is white. Of course the modal is white. Of course the icon on the primary button is white.&lt;/p&gt;
&lt;p&gt;Until the app theme changes and that same “obvious” value becomes the one thing refusing to adapt.&lt;/p&gt;
&lt;p&gt;The suspicious innocence of Colors.white&lt;/p&gt;
&lt;p&gt;The cleanup made me notice how often Flutter code uses color constants as if they are design decisions instead of implementation details.&lt;/p&gt;
&lt;p&gt;This kind of code is easy to write:&lt;/p&gt;
&lt;p&gt;Container(&lt;br&gt;
  color: Colors.white,&lt;br&gt;
  child: Text(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title,
style: const TextStyle(color: Colors.black),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  ),&lt;br&gt;
)&lt;/p&gt;
&lt;p&gt;There is nothing technically wrong with it. The widget renders. The code is readable. Everyone understands what it does.&lt;/p&gt;
&lt;p&gt;But architecturally, it is making a decision in the wrong place.&lt;/p&gt;
&lt;p&gt;The widget is deciding that the surface must be white and the text must be black. That may be true today, but it is not the widget's job to know forever. Once dark mode enters the conversation, that decision belongs somewhere higher up: a theme, token system, semantic color layer, or at the very least a central color class.&lt;/p&gt;
&lt;p&gt;So the goal was not to make the code prettier. It was to reduce the number of tiny places that would later need to “remember” how dark mode works.&lt;/p&gt;
&lt;p&gt;Centralization is not the same as theming, but it is a start&lt;/p&gt;
&lt;p&gt;The app already had an AppColors class, which was good. It meant the project had a place where visual constants could live.&lt;/p&gt;
&lt;p&gt;The messy part was that some colors were already centralized while others were still written directly inside widgets. A few values already existed in AppColors, just under literal-style names. Some did not exist yet. Some were Flutter palette values like Colors.grey[400], which are convenient in the moment and annoying later.&lt;/p&gt;
&lt;p&gt;The rule for the cleanup became very plain:&lt;/p&gt;
&lt;p&gt;If the value already existed in AppColors, use it.&lt;/p&gt;
&lt;p&gt;If it did not exist, add a static constant.&lt;/p&gt;
&lt;p&gt;Do not rename the entire design system today.&lt;/p&gt;
&lt;p&gt;Do not pretend this is a full dark mode implementation.&lt;/p&gt;
&lt;p&gt;Just make sure the color literals stop leaking through the UI.&lt;/p&gt;
&lt;p&gt;That last point mattered. It is tempting during a cleanup like this to turn it into a full semantic-token refactor:&lt;/p&gt;
&lt;p&gt;AppColors.surfacePrimary&lt;br&gt;
AppColors.surfaceElevated&lt;br&gt;
AppColors.textPrimary&lt;br&gt;
AppColors.textMuted&lt;br&gt;
AppColors.borderDefault&lt;/p&gt;
&lt;p&gt;Honestly, that is probably where the app should end up.&lt;/p&gt;
&lt;p&gt;But today was not the day to redesign the whole color vocabulary. The immediate goal was containment. Before a color can become theme-aware, it needs to stop being scattered across a hundred widgets.&lt;/p&gt;
&lt;p&gt;So even constants like this were useful:&lt;/p&gt;
&lt;p&gt;class AppColors {&lt;br&gt;
  static const Color white = Color(0xFFFFFFFF);&lt;br&gt;
  static const Color black = Color(0xFF000000);&lt;br&gt;
  static const Color transparent = Color(0x00000000);&lt;/p&gt;
&lt;p&gt;  static const Color grey200 = Color(0xFFEEEEEE);&lt;br&gt;
  static const Color grey400 = Color(0xFFBDBDBD);&lt;br&gt;
  static const Color grey600 = Color(0xFF757575);&lt;/p&gt;
&lt;p&gt;  static const Color background = Color(0xFFFEFEFE);&lt;br&gt;
  static const Color border = Color(0xFFE8E8E8);&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;Not perfect. But centralized. And centralized is the first real step toward controllable.&lt;/p&gt;
&lt;p&gt;The part that quietly mattered&lt;/p&gt;
&lt;p&gt;One interesting detail was deciding what counted as a color problem.&lt;/p&gt;
&lt;p&gt;A direct Color(0xFFAF6D57) in a widget is obviously a problem. It is a visual value hardcoded into UI code.&lt;/p&gt;
&lt;p&gt;A dynamic parser like this is different:&lt;/p&gt;
&lt;p&gt;Color(int.parse(cleanedHexValue, radix: 16));&lt;/p&gt;
&lt;p&gt;That is not really a hardcoded design color. It is converting a value that came from elsewhere, probably an API or configuration payload. Centralizing that would be a different conversation: validation, fallback behavior, allowed palettes, maybe server-driven UI constraints.&lt;/p&gt;
&lt;p&gt;So I left dynamic parsing alone and focused on literals: Colors.*, Color(0x...), and raw hex strings that represented fixed UI choices.&lt;/p&gt;
&lt;p&gt;This is the kind of small boundary decision that does not look exciting in a diff, but it keeps a refactor from becoming a swamp.&lt;/p&gt;
&lt;p&gt;Search and replace, with consequences&lt;/p&gt;
&lt;p&gt;Most of the work was mechanical, but mechanical does not mean brainless.&lt;/p&gt;
&lt;p&gt;For example, replacing Colors.grey with a color constant is not the same as replacing Colors.grey[400]. Flutter's material color swatches have shades, while a plain Color does not.&lt;/p&gt;
&lt;p&gt;This was one of those tiny compiler reminders that the original code was relying on more than just a color value:&lt;/p&gt;
&lt;p&gt;// Before&lt;br&gt;
Paint()..color = Colors.grey.shade400;&lt;/p&gt;
&lt;p&gt;// After&lt;br&gt;
Paint()..color = AppColors.grey400;&lt;/p&gt;
&lt;p&gt;That one line explains a lot. The old code depended on a MaterialColor API. The centralized version needed a concrete shade constant.&lt;/p&gt;
&lt;p&gt;Another small gotcha: when doing broad replacements, the color class itself has to be protected. If a replacement pass rewrites the constants inside AppColors, you can accidentally create recursive constants like:&lt;/p&gt;
&lt;p&gt;static const Color white = AppColors.white;&lt;/p&gt;
&lt;p&gt;Which is both funny and very much not useful.&lt;/p&gt;
&lt;p&gt;The analyzer caught that immediately. A mildly embarrassing moment, but also a good reminder: refactoring with tools is still refactoring. The compiler is part of the conversation.&lt;/p&gt;
&lt;p&gt;Why this matters before dark mode&lt;/p&gt;
&lt;p&gt;The easiest dark mode bugs are the ones you can see instantly: black text on a dark background, white cards floating awkwardly on a dark scaffold, disabled buttons with contrast that disappears.&lt;/p&gt;
&lt;p&gt;The harder bugs are the ones caused by ownership confusion.&lt;/p&gt;
&lt;p&gt;If every widget owns its own color decisions, then dark mode becomes a scavenger hunt. You change the theme, run the app, notice one broken screen, patch it, then find another. A bottom sheet here. A map overlay there. A custom painter somewhere else. Eventually you are debugging vibes.&lt;/p&gt;
&lt;p&gt;Centralizing colors changes the shape of the work. It does not magically create dark mode, but it makes the next step sane. Instead of asking, “Where did this white come from?” you can ask, “Which token should this surface use?”&lt;/p&gt;
&lt;p&gt;That is a much better question.&lt;/p&gt;
&lt;p&gt;It also creates room for a future migration from raw constants to semantic ones. Today, AppColors.white may still be literal white. Later, a component might use AppThemeColors.surface(context) or a theme extension. That migration is much easier when the codebase is not full of anonymous hex values.&lt;/p&gt;
&lt;p&gt;The small win&lt;/p&gt;
&lt;p&gt;By the end of the cleanup, the app had no remaining direct Colors.* usage outside the central theme file, no direct Color(0x...) UI literals outside AppColors, and no raw hex color strings floating around in feature code.&lt;/p&gt;
&lt;p&gt;The analyzer still had unrelated warnings, because real apps always have a little background noise. But the color cleanup itself was clean.&lt;/p&gt;
&lt;p&gt;That felt like progress.&lt;/p&gt;
&lt;p&gt;Not the shiny kind where a new feature appears on screen. More like the quieter kind where future work becomes less annoying because today's code stopped hiding decisions in random corners.&lt;/p&gt;
&lt;p&gt;And honestly, that is a big part of mobile development. Some days you build screens. Some days you chase state bugs. Some days you move every Colors.white into one place because future-you deserves fewer headaches.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17769/the-day-white-became-a-bug</guid>
<pubDate>Wed, 13 May 2026 20:46:52 +0000</pubDate>
</item>
<item>
<title>I Built a Complete Zomato-Style Food Delivery Platform in Flutter — Here's What I Learned</title>
<link>https://coderlegion.com/17269/i-built-a-complete-zomato-style-food-delivery-platform-in-flutter-heres-what-i-learned</link>
<description>&lt;p&gt;Just shipped Zesto — a full food delivery ecosystem with 4 Flutter&lt;br&gt;
apps and a Firebase backend. Here's what the build taught me.&lt;/p&gt;
&lt;p&gt;What's in the project&lt;/p&gt;
&lt;p&gt; Customer App — OTP login, restaurant discovery, cart,&lt;br&gt;
Razorpay payments, live driver tracking on Google Maps&lt;br&gt;
 Driver App — auto job assignment, step-by-step delivery&lt;br&gt;
slider, background GPS foreground service&lt;br&gt;
️ Restaurant App — real-time order dashboard, menu management&lt;br&gt;
️ Admin Panel — Flutter Web, multi-city management&lt;/p&gt;
&lt;p&gt;Backend: 20+Firebase Cloud Functions (Node.js 18)&lt;/p&gt;
&lt;p&gt;3 Things That Bit Me Hard&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Never trust client-side prices&lt;br&gt;
My original placeOrder Cloud Function accepted grand_total from&lt;br&gt;
the app. Anyone could send grand_total: 1 and get free food.&lt;br&gt;
Fixed by recalculating everything server-side from Firestore menu&lt;br&gt;
prices — never touching the client value.&lt;/li&gt;
&lt;li&gt;Status string consistency across 4 apps&lt;br&gt;
Flutter enums use camelCase by default (arrivedAtRestaurant).&lt;br&gt;
Firestore stored snake_case (arrived_at_restaurant). Silent&lt;br&gt;
mismatches everywhere. Lesson: standardize from day one and use&lt;br&gt;
a safe parser that never throws on unknown values.&lt;/li&gt;
&lt;li&gt;MobX reactions fire on every change — not just the first one&lt;br&gt;
My HomeScreen reaction was pushing a new delivery screen every time&lt;br&gt;
activeJob updated — including status changes while the driver was&lt;br&gt;
already on the delivery screen. Three screens deep by the time the&lt;br&gt;
order was delivered. A single boolean guard fixed it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Tech Stack&lt;br&gt;
Flutter 3.11+ · Dart · Firebase · Razorpay&lt;br&gt;
Google Maps · MobX · Node.js 18 · GeoFlutterFire&lt;/p&gt;
&lt;p&gt;The source code is available&lt;br&gt;
If you're building something similar or want a real production&lt;br&gt;
Flutter/Firebase codebase to learn from:&lt;br&gt;
 &lt;a rel=&quot;nofollow&quot; href=&quot;https://morningstar47jb.gumroad.com/l/tnevss&quot;&gt;https://morningstar47jb.gumroad.com/l/tnevss&lt;/a&gt;&lt;br&gt;
Demo video: &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtube.com/shorts/rL2M5O5Tk50&quot;&gt;https://youtube.com/shorts/rL2M5O5Tk50&lt;/a&gt;&lt;br&gt;
Happy to answer any architecture questions in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17269/i-built-a-complete-zomato-style-food-delivery-platform-in-flutter-heres-what-i-learned</guid>
<pubDate>Sun, 10 May 2026 10:40:13 +0000</pubDate>
</item>
<item>
<title>The Coordinated BLoC Pattern in Flutter — A Practical Guide</title>
<link>https://coderlegion.com/17135/the-coordinated-bloc-pattern-in-flutter-a-practical-guide</link>
<description>&lt;h2 id=&quot;the-coordinated-bloc-pattern-in-flutter-a-practical-guide&quot;&gt;The Coordinated BLoC Pattern in Flutter — A Practical Guide&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;This post is based on real production code. Every mistake documented here was hit in the wild, debugged, and understood the hard way.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At first, coordinating multiple BLoCs from &lt;code&gt;initState&lt;/code&gt; feels harmless.&lt;/p&gt;
&lt;p&gt;Then the screen grows.&lt;/p&gt;
&lt;p&gt;Now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;some data depends on the authenticated user&lt;/li&gt;
&lt;li&gt;some sections support silent refresh&lt;/li&gt;
&lt;li&gt;some should show loading spinners&lt;/li&gt;
&lt;li&gt;some should skip fetching if cached&lt;/li&gt;
&lt;li&gt;pull-to-refresh has different behaviour than initial load&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And suddenly your screen becomes the orchestration layer for half the app.&lt;/p&gt;
&lt;p&gt;This article covers a pattern for extracting that orchestration into its own BLoC: the &lt;strong&gt;Coordination BLoC pattern&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;More importantly, it covers the production mistakes this pattern introduces — especially the ones that fail silently.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-problem-does-this-solve-&quot;&gt;What Problem Does This Solve?&lt;/h2&gt;
&lt;p&gt;Imagine a home screen that needs data from three different sources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;meditation groups&lt;/li&gt;
&lt;li&gt;saadhna progress&lt;/li&gt;
&lt;li&gt;user favourites&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each has its own BLoC.&lt;br&gt;
Each BLoC is independent.&lt;br&gt;
But they all need to be triggered together after the user's profile loads.&lt;/p&gt;
&lt;p&gt;You also want smart refresh behaviour:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;if data is fresh → silently refresh in background&lt;/li&gt;
&lt;li&gt;if data is missing → show loading state&lt;/li&gt;
&lt;li&gt;pull-to-refresh should force reload everything&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Naively, you handle all of this inside &lt;code&gt;initState&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
void initState() {
  super.initState();

  final profileState = context.read&amp;lt;ProfileBloc&amp;gt;().state;

  if (profileState is ProfileLoaded) {
    context.read&amp;lt;HomeMeditationGroupsBloc&amp;gt;()
        .add(FetchHomeMeditationGroups());

    context.read&amp;lt;SaadhnaBloc&amp;gt;()
        .add(FetchAllSaadhnaGroups());

    context.read&amp;lt;UserFavouritesBloc&amp;gt;()
        .add(FetchUserFavouriteMeditations(profileState.id));
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first this works fine.&lt;/p&gt;
&lt;p&gt;Then more conditions appear:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;freshness checks&lt;/li&gt;
&lt;li&gt;silent refreshes&lt;/li&gt;
&lt;li&gt;auth dependencies&lt;/li&gt;
&lt;li&gt;feature flags&lt;/li&gt;
&lt;li&gt;retry behaviour&lt;/li&gt;
&lt;li&gt;analytics&lt;/li&gt;
&lt;li&gt;cross-bloc coordination&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And now your screen knows too much about how loading works.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Coordination BLoC pattern&lt;/strong&gt; extracts orchestration logic into its own BLoC.&lt;/p&gt;
&lt;p&gt;The screen fires one high-level event.&lt;br&gt;
The coordination bloc decides what happens.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;mental-model&quot;&gt;Mental Model&lt;/h2&gt;
&lt;p&gt;The coordination bloc owns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;when&lt;/strong&gt; things load&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;why&lt;/strong&gt; they load&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;which events&lt;/strong&gt; should fire&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The feature blocs still own:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;data&lt;/li&gt;
&lt;li&gt;business logic&lt;/li&gt;
&lt;li&gt;rendering state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The coordination bloc orchestrates.&lt;br&gt;
It does not aggregate UI state.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    ProfileBloc
                         │
                         ▼
             HomeCoordinationBloc
                         │
      ┌──────────────────┼──────────────────┐
      ▼                  ▼                  ▼
HomeMeditationBloc   SaadhnaBloc   UserFavouritesBloc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The coordination bloc becomes the single orchestration point for screen-level loading behaviour.&lt;/p&gt;
&lt;p&gt;The screen no longer knows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;which blocs exist&lt;/li&gt;
&lt;li&gt;which events they need&lt;/li&gt;
&lt;li&gt;which dependencies they have&lt;/li&gt;
&lt;li&gt;which refresh strategy they use&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;
&lt;h3 id=&quot;1-events&quot;&gt;1. Events&lt;/h3&gt;
&lt;p&gt;Keep events coarse-grained and intent-based.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;sealed class HomeCoordinatorEvent {}

class HomeDataFetch extends HomeCoordinatorEvent {}

class HomeDataRefresh extends HomeCoordinatorEvent {}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;event-intent&quot;&gt;Event Intent&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Event &lt;/th&gt;&lt;th&gt;Meaning&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;HomeDataFetch&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Smart load with freshness checks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;HomeDataRefresh&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Force reload everything&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-states&quot;&gt;2. States&lt;/h3&gt;
&lt;p&gt;The coordination bloc does &lt;strong&gt;not&lt;/strong&gt; own feature data.&lt;/p&gt;
&lt;p&gt;Its responsibility is orchestration, not aggregation.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;sealed class HomeCoordinatorState {}

class HomeCoordinatorInitialState
    extends HomeCoordinatorState {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The UI still reads directly from feature blocs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;BlocBuilder&amp;lt;HomeMeditationGroupsBloc,
    HomeMeditationGroupsState&amp;gt;(
  builder: (context, state) {
    // render UI
  },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This separation is important.&lt;/p&gt;
&lt;p&gt;The coordination bloc coordinates behaviour.&lt;br&gt;
The feature blocs remain the source of truth.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-coordination-bloc&quot;&gt;3. The Coordination BLoC&lt;/h3&gt;
&lt;h4 id=&quot;the-key-idea&quot;&gt;The key idea&lt;/h4&gt;
&lt;p&gt;The screen fires one event:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;HomeDataFetch()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The coordination bloc decides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;which blocs should refresh&lt;/li&gt;
&lt;li&gt;whether data is stale&lt;/li&gt;
&lt;li&gt;whether loading should be silent&lt;/li&gt;
&lt;li&gt;whether user context is required&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class HomeCoordinationBloc
    extends Bloc&amp;lt;HomeCoordinatorEvent,
        HomeCoordinatorState&amp;gt; {

  final HomeMeditationGroupsBloc
      homeMeditationGroupsBloc;

  final SaadhnaBloc saadhnaBloc;

  final UserFavouritesBloc
      userFavouritesBloc;

  final ProfileBloc profileBloc;

  late StreamSubscription profileSubscription;

  HomeCoordinationBloc({
    required this.homeMeditationGroupsBloc,
    required this.saadhnaBloc,
    required this.userFavouritesBloc,
    required this.profileBloc,
  }) : super(HomeCoordinatorInitialState()) {

    // Register handlers FIRST
    on&amp;lt;HomeDataFetch&amp;gt;(_fetchData);
    on&amp;lt;HomeDataRefresh&amp;gt;(_refetchData);

    // Trigger immediately if already loaded
    if (profileBloc.state is ProfileLoaded) {
      add(HomeDataFetch());
    }

    // Otherwise wait for profile load
    profileSubscription = profileBloc.stream
        .where((state) =&amp;gt; state is ProfileLoaded)
        .take(1)
        .listen((_) {
          add(HomeDataFetch());
        });
  }

  void _fetchData(
    HomeDataFetch event,
    Emitter emit,
  ) {
    final userId =
        (profileBloc.state as ProfileLoaded).id;

    final isMeditationFresh =
        homeMeditationGroupsBloc.state
            is HomeMeditationGroupsFetched;

    final isSaadhnaFresh =
        saadhnaBloc.state
            is SaadhnaGroupsFetched;

    final isFavFresh =
        userFavouritesBloc.state
            is UserFavouritesFetched;

    homeMeditationGroupsBloc.add(
      isMeditationFresh
          ? SilentRefreshHomeMeditationGroups()
          : FetchHomeMeditationGroups(),
    );

    saadhnaBloc.add(
      isSaadhnaFresh
          ? RefetchAllSaadhnaGroups()
          : FetchAllSaadhnaGroups(),
    );

    userFavouritesBloc.add(
      isFavFresh
          ? RefetchUserFavouriteMeditations(userId)
          : FetchUserFavouriteMeditations(userId),
    );
  }

  void _refetchData(
    HomeDataRefresh event,
    Emitter emit,
  ) {
    final userId =
        (profileBloc.state as ProfileLoaded).id;

    homeMeditationGroupsBloc.add(
      RefreshHomeMeditationGroups(),
    );

    saadhnaBloc.add(
      FetchAllSaadhnaGroups(),
    );

    userFavouritesBloc.add(
      RefetchUserFavouriteMeditations(userId),
    );
  }

  @override
  Future&amp;lt;void&amp;gt; close() {
    profileSubscription.cancel();
    return super.close();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;dependency-injection-getit-&quot;&gt;Dependency Injection (GetIt)&lt;/h2&gt;
&lt;p&gt;This is where most production bugs come from.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;sl.registerLazySingleton(
  () =&amp;gt; HomeMeditationGroupsBloc(
    getMeditationGroupsByType: sl(),
  ),
);

sl.registerLazySingleton(
  () =&amp;gt; SaadhnaBloc(
    getAllGroups: sl(),
  ),
);

sl.registerLazySingleton(
  () =&amp;gt; UserFavouritesBloc(),
);

sl.registerLazySingleton(
  () =&amp;gt; HomeCoordinationBloc(
    homeMeditationGroupsBloc: sl(),
    saadhnaBloc: sl(),
    userFavouritesBloc: sl(),
    profileBloc: sl(),
  ),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;bloc-providers&quot;&gt;BLoC Providers&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;BlocProvider&amp;lt;UserFavouritesBloc&amp;gt;(
  create: (context) =&amp;gt; sl&amp;lt;UserFavouritesBloc&amp;gt;(),
),

BlocProvider&amp;lt;HomeMeditationGroupsBloc&amp;gt;(
  create: (context)
      =&amp;gt; sl&amp;lt;HomeMeditationGroupsBloc&amp;gt;(),
),

BlocProvider&amp;lt;SaadhnaBloc&amp;gt;(
  create: (context) =&amp;gt; sl&amp;lt;SaadhnaBloc&amp;gt;(),
),

BlocProvider&amp;lt;HomeCoordinationBloc&amp;gt;(
  create: (context)
      =&amp;gt; sl&amp;lt;HomeCoordinationBloc&amp;gt;(),
),
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-screen&quot;&gt;The Screen&lt;/h2&gt;
&lt;p&gt;The screen now knows nothing about orchestration.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
Widget build(BuildContext context) {
  return LiquidPullToRefresh(
    onRefresh: () async {
      context.read&amp;lt;HomeCoordinationBloc&amp;gt;()
          .add(HomeDataRefresh());
    },
    child: ListView(
      children: [
        BlocBuilder&amp;lt;
            HomeMeditationGroupsBloc,
            HomeMeditationGroupsState&amp;gt;(
          builder: (context, state) {
            // render section
          },
        ),
      ],
    ),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;mistakes-to-not-make&quot;&gt;Mistakes to Not Make&lt;/h2&gt;
&lt;h3 id=&quot;mistake-1-registering-handlers-after-calling-add-&quot;&gt;Mistake 1: Registering Handlers After Calling &lt;code&gt;add()&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This causes a runtime &lt;code&gt;Bad state&lt;/code&gt; error.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// WRONG
HomeCoordinationBloc(...)
    : super(HomeCoordinatorInitialState()) {

  if (profileBloc.state is ProfileLoaded) {
    add(HomeDataFetch());
  }

  on&amp;lt;HomeDataFetch&amp;gt;(_fetchData);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// CORRECT
HomeCoordinationBloc(...)
    : super(HomeCoordinatorInitialState()) {

  on&amp;lt;HomeDataFetch&amp;gt;(_fetchData);

  if (profileBloc.state is ProfileLoaded) {
    add(HomeDataFetch());
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The BLoC library checks for a handler immediately when &lt;code&gt;add()&lt;/code&gt; is called.&lt;/p&gt;
&lt;p&gt;Constructor order matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mistake-2-using-registerfactory&quot;&gt;Mistake 2: Using &lt;code&gt;registerFactory&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This bug is especially nasty because everything appears to work — except the UI never updates.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// WRONG
sl.registerFactory(
  () =&amp;gt; HomeMeditationGroupsBloc(...),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What happens:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Coordination bloc gets Instance A&lt;/li&gt;
&lt;li&gt;UI listens to Instance B&lt;/li&gt;
&lt;li&gt;Events fire correctly&lt;/li&gt;
&lt;li&gt;Data loads correctly&lt;/li&gt;
&lt;li&gt;UI never rebuilds&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two blocs.&lt;br&gt;
Two states.&lt;br&gt;
Zero visible errors.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// CORRECT
sl.registerLazySingleton(
  () =&amp;gt; HomeMeditationGroupsBloc(...),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;rule&quot;&gt;Rule&lt;/h4&gt;
&lt;p&gt;Any bloc referenced by a coordination bloc should usually be a singleton.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mistake-3-forgetting-to-cancel-stream-subscriptions&quot;&gt;Mistake 3: Forgetting to Cancel Stream Subscriptions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// WRONG
@override
Future&amp;lt;void&amp;gt; close() =&amp;gt; super.close();
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// CORRECT
@override
Future&amp;lt;void&amp;gt; close() {
  profileSubscription.cancel();
  return super.close();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Otherwise the subscription can attempt to call &lt;code&gt;add()&lt;/code&gt; on a closed bloc.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mistake-4-forgetting-take-1-&quot;&gt;Mistake 4: Forgetting &lt;code&gt;.take(1)&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Without &lt;code&gt;.take(1)&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;profileBloc.stream
    .where((state) =&amp;gt; state is ProfileLoaded)
    .listen((_) =&amp;gt; add(HomeDataFetch()));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every future &lt;code&gt;ProfileLoaded&lt;/code&gt; state retriggers fetches.&lt;/p&gt;
&lt;p&gt;That can create phantom refreshes after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;token refreshes&lt;/li&gt;
&lt;li&gt;profile edits&lt;/li&gt;
&lt;li&gt;re-authentication&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Usually you only want the first load:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;profileBloc.stream
    .where((state) =&amp;gt; state is ProfileLoaded)
    .take(1)
    .listen((_) =&amp;gt; add(HomeDataFetch()));
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mistake-5-mixing-getit-and-context-read&quot;&gt;Mistake 5: Mixing &lt;code&gt;GetIt&lt;/code&gt; and &lt;code&gt;context.read&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Confusing and unnecessary
_homeCoordinationBloc =
    GetIt.instance&amp;lt;HomeCoordinationBloc&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;context.read&amp;lt;HomeCoordinationBloc&amp;gt;()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pick one access pattern.&lt;/p&gt;
&lt;p&gt;If the bloc comes from &lt;code&gt;BlocProvider&lt;/code&gt;,&lt;br&gt;
prefer &lt;code&gt;context.read&amp;lt;X&amp;gt;()&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mistake-6-leaving-half-the-logic-in-the-screen&quot;&gt;Mistake 6: Leaving Half the Logic in the Screen&lt;/h3&gt;
&lt;p&gt;Once you introduce a coordination bloc, commit to it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Split ownership
context.read&amp;lt;HomeCoordinationBloc&amp;gt;()
    .add(HomeDataFetch());

context.read&amp;lt;SaadhnaBloc&amp;gt;()
    .add(FetchAllSaadhnaGroups());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now orchestration lives in two places.&lt;/p&gt;
&lt;p&gt;That is worse than either approach alone.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-hidden-cost-forced-singletons&quot;&gt;The Hidden Cost: Forced Singletons&lt;/h2&gt;
&lt;p&gt;This pattern introduces a real architectural tradeoff.&lt;/p&gt;
&lt;p&gt;Your feature blocs now live for the entire app lifecycle.&lt;/p&gt;
&lt;p&gt;That means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cached state survives navigation&lt;/li&gt;
&lt;li&gt;previous data persists&lt;/li&gt;
&lt;li&gt;memory usage increases&lt;/li&gt;
&lt;li&gt;state does not automatically reset&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is usually correct for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;home feeds&lt;/li&gt;
&lt;li&gt;dashboards&lt;/li&gt;
&lt;li&gt;cached content&lt;/li&gt;
&lt;li&gt;long-lived tabs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is usually dangerous for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;forms&lt;/li&gt;
&lt;li&gt;temporary workflows&lt;/li&gt;
&lt;li&gt;checkout flows&lt;/li&gt;
&lt;li&gt;wizard-style screens&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do not accidentally turn screen-scoped state into app-scoped state.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-to-use-this-pattern&quot;&gt;When To Use This Pattern&lt;/h2&gt;
&lt;p&gt;Use it when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3+ blocs must coordinate together&lt;/li&gt;
&lt;li&gt;another bloc's state gates loading&lt;/li&gt;
&lt;li&gt;loading logic contains real conditions&lt;/li&gt;
&lt;li&gt;orchestration is reused across screens&lt;/li&gt;
&lt;li&gt;refresh behaviour is non-trivial&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-not-to-use-it&quot;&gt;When NOT To Use It&lt;/h2&gt;
&lt;p&gt;Do not use it when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;you're only triggering 1-2 blocs&lt;/li&gt;
&lt;li&gt;coordination is just sequential &lt;code&gt;add()&lt;/code&gt; calls&lt;/li&gt;
&lt;li&gt;the screen logic is still simple&lt;/li&gt;
&lt;li&gt;feature blocs should remain screen-scoped&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In those cases, a &lt;code&gt;BlocListener&lt;/code&gt; is simpler and cleaner.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-simpler-alternative&quot;&gt;The Simpler Alternative&lt;/h2&gt;
&lt;p&gt;For smaller flows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;BlocListener&amp;lt;ProfileBloc, ProfileState&amp;gt;(
  listenWhen: (prev, curr) =&amp;gt;
      curr is ProfileLoaded &amp;amp;&amp;amp;
      prev is! ProfileLoaded,

  listener: (context, state) {
    final userId =
        (state as ProfileLoaded).id;

    context.read&amp;lt;HomeMeditationGroupsBloc&amp;gt;()
        .add(FetchHomeMeditationGroups());

    context.read&amp;lt;SaadhnaBloc&amp;gt;()
        .add(FetchAllSaadhnaGroups());

    context.read&amp;lt;UserFavouritesBloc&amp;gt;()
        .add(
          FetchUserFavouriteMeditations(
            userId,
          ),
        );
  },

  child: YourScreen(),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This solution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;is smaller&lt;/li&gt;
&lt;li&gt;avoids singletons&lt;/li&gt;
&lt;li&gt;avoids stream subscriptions&lt;/li&gt;
&lt;li&gt;avoids an extra bloc entirely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Start here first.&lt;/p&gt;
&lt;p&gt;Graduate to coordination blocs only when the orchestration becomes genuinely complex.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Coordination BLoC &lt;/th&gt;&lt;th&gt;BlocListener&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Complexity &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Testability &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Reusability &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Singleton Requirement &lt;/td&gt;&lt;td&gt;Usually Yes &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best For &lt;/td&gt;&lt;td&gt;Complex orchestration &lt;/td&gt;&lt;td&gt;Simple fan-out logic&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The Coordination BLoC pattern is a real solution for real complexity.&lt;/p&gt;
&lt;p&gt;The mistake is introducing it too early.&lt;/p&gt;
&lt;p&gt;Start simple.&lt;br&gt;
Escalate only when the orchestration actually demands it.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/17135/the-coordinated-bloc-pattern-in-flutter-a-practical-guide</guid>
<pubDate>Sat, 09 May 2026 08:12:03 +0000</pubDate>
</item>
<item>
<title>When a Button Should Do More Than Close</title>
<link>https://coderlegion.com/16983/when-a-button-should-do-more-than-close</link>
<description>&lt;p&gt;Some days in Flutter are not about huge refactors or dramatic crashes. Some days are about a button that says one thing and does another.&lt;/p&gt;
&lt;p&gt;Today’s task looked harmless: clean up a few dialog buttons in an event-planning mobile app. The dialogs were already showing at the right moments. The UI looked fine enough. The copy was clear.&lt;/p&gt;
&lt;p&gt;But then I tapped the buttons mentally and noticed the problem: most of them just closed the dialog.&lt;/p&gt;
&lt;p&gt;That is not always wrong. Sometimes a dialog is only a nudge. Sometimes “Okay” really does mean “go away, I understand.” But if a button says “View Pending Tasks” and then only pops the modal, that is a tiny betrayal. Not catastrophic, not release-blocking, just one of those product papercuts that makes an app feel less intentional.&lt;/p&gt;
&lt;p&gt;So the day became less about button styling and more about answering a deceptively annoying question:&lt;/p&gt;
&lt;h3 id=&quot;the-dialog-was-innocent&quot;&gt;The dialog was innocent&lt;/h3&gt;
&lt;p&gt;The dialog components themselves were simple Flutter widgets. A few icons, some copy, and an &lt;code&gt;ElevatedButton&lt;/code&gt; at the bottom.&lt;/p&gt;
&lt;p&gt;The original pattern looked roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class DayDialog extends StatelessWidget {
  const DayDialog({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        Navigator.pop(context);
      },
      child: const Text(&quot;Let's Go&quot;),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is nothing technically broken about that. It compiles. It works. The dialog closes.&lt;/p&gt;
&lt;p&gt;But the app had dialogs with stronger intent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“It’s D-Day” should take the user to the event happening today.&lt;/li&gt;
&lt;li&gt;“You’ve got tasks waiting” should take the user somewhere useful for those tasks.&lt;/li&gt;
&lt;li&gt;“Missed something?” should open notifications.&lt;/li&gt;
&lt;li&gt;“One step closer” might honestly just dismiss, because it is more encouragement than navigation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last distinction ended up mattering. Not every button needs to become a route change just because we are in a “make buttons useful” mood.&lt;/p&gt;
&lt;h3 id=&quot;the-first-useful-clue-don-t-make-the-dialog-smart&quot;&gt;The first useful clue: don’t make the dialog smart&lt;/h3&gt;
&lt;p&gt;The tempting mistake would have been to import screens into every dialog and let the dialog figure out where to navigate.&lt;/p&gt;
&lt;p&gt;That would work for about five minutes.&lt;/p&gt;
&lt;p&gt;Then the dialog layer would start knowing about event models, notification screens, task picking rules, dashboard cache, route transitions, and probably someone’s lunch order if I looked away long enough.&lt;/p&gt;
&lt;p&gt;The cleaner move was to keep the dialogs dumb and let the screen that shows them inject behavior.&lt;/p&gt;
&lt;p&gt;So the dialog becomes callback-driven:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class DayDialog extends StatelessWidget {
  final VoidCallback? onLetsGo;

  const DayDialog({
    super.key,
    this.onLetsGo,
  });

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        if (onLetsGo != null) {
          onLetsGo!();
          return;
        }

        Navigator.pop(context);
      },
      child: const Text(&quot;Let's Go&quot;),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a small change, but it keeps the boundary clean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The dialog owns presentation.&lt;/li&gt;
&lt;li&gt;The home screen owns app state.&lt;/li&gt;
&lt;li&gt;Navigation is decided where the relevant state already exists.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Flutter makes it very easy to blur those lines because &lt;code&gt;BuildContext&lt;/code&gt; is everywhere. That convenience is lovely until every widget becomes slightly aware of the entire app.&lt;/p&gt;
&lt;h3 id=&quot;d-day-was-the-easy-one&quot;&gt;D-Day was the easy one&lt;/h3&gt;
&lt;p&gt;The D-Day dialog had a nice property: the event it referred to was already known.&lt;/p&gt;
&lt;p&gt;The home screen was computing the earliest upcoming event and storing it as a mapped event model. The D-Day eligibility check was based on that same event date. In plain terms, if the app says “your event starts now,” then the event to open should be the earliest event that made that condition true.&lt;/p&gt;
&lt;p&gt;That gave us a clean action:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;DayDialog(
  onLetsGo: () {
    final event = earliestEvent;
    Navigator.of(dialogContext).pop();

    if (event == null || !mounted) return;

    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) =&amp;gt; EventDetailsScreen(event: event),
      ),
    );
  },
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are two &lt;code&gt;context&lt;/code&gt;s here, and that is not accidental.&lt;/p&gt;
&lt;p&gt;The dialog builder gives a &lt;code&gt;dialogContext&lt;/code&gt;, which is the safest context for popping the dialog. The screen’s own &lt;code&gt;context&lt;/code&gt; is then used for pushing the next route.&lt;/p&gt;
&lt;p&gt;Could one context have worked? Probably. But “probably” is not my favorite navigation strategy, especially around dialogs. Flutter navigation bugs tend to wait until a specific route stack shape appears, then quietly ruin your afternoon.&lt;/p&gt;
&lt;h3 id=&quot;the-task-dialog-was-less-polite&quot;&gt;The task dialog was less polite&lt;/h3&gt;
&lt;p&gt;The pending task dialog sounded simple:&lt;/p&gt;
&lt;p&gt;Cool. Open pending tasks.&lt;/p&gt;
&lt;p&gt;Except there was no single “pending tasks screen.” Also, tasks belonged to different events.&lt;/p&gt;
&lt;p&gt;A dashboard task had enough information to know that it was incomplete and which event it belonged to, but the event details screen needed a fuller event model. So the app had two related but different pieces of state:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;dashboard task summaries&lt;/li&gt;
&lt;li&gt;full mapped events from the events response&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That meant the button needed a selection rule.&lt;/p&gt;
&lt;p&gt;We considered a few choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;open the calendar, which is safe but broad&lt;/li&gt;
&lt;li&gt;build a grouped pending tasks view, which is better UX but more work&lt;/li&gt;
&lt;li&gt;open the event containing the next task needing attention&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last option felt like the best daily tradeoff. It makes the button do something concrete without inventing a new screen.&lt;/p&gt;
&lt;p&gt;The rule became:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find incomplete tasks.&lt;/li&gt;
&lt;li&gt;Prefer the nearest upcoming incomplete task.&lt;/li&gt;
&lt;li&gt;If all incomplete tasks are overdue, pick the most recently overdue one.&lt;/li&gt;
&lt;li&gt;Find the event that owns that task.&lt;/li&gt;
&lt;li&gt;Open event details.&lt;/li&gt;
&lt;li&gt;If the event cannot be found, fall back to the calendar.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That sounds like a lot for one button, but it is really just product intent expressed as code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;TaskSummary? findTaskNeedingAttention(List&amp;lt;TaskSummary&amp;gt; tasks) {
  final pending = tasks.where((task) =&amp;gt; !task.isCompleted).toList();
  if (pending.isEmpty) return null;

  final now = DateTime.now();

  final upcoming = pending
      .where((task) =&amp;gt; task.date != null &amp;amp;&amp;amp; !task.date!.isBefore(now))
      .toList()
    ..sort((a, b) =&amp;gt; a.date!.compareTo(b.date!));

  if (upcoming.isNotEmpty) return upcoming.first;

  final overdue = pending.where((task) =&amp;gt; task.date != null).toList()
    ..sort((a, b) =&amp;gt; b.date!.compareTo(a.date!));

  if (overdue.isNotEmpty) return overdue.first;

  return pending.first;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fallback mattered to me. Real mobile state is messy. Maybe the dashboard response has a task whose event is missing from the events response. Maybe caching gives you a slightly stale combination. Maybe the backend shape changes in some tiny way on a tired Tuesday.&lt;/p&gt;
&lt;p&gt;In those cases, the app should still take the user somewhere useful instead of turning the button into a dead end.&lt;/p&gt;
&lt;h3 id=&quot;the-callback-pattern-held-up&quot;&gt;The callback pattern held up&lt;/h3&gt;
&lt;p&gt;Once the dialog had a callback, the home screen could own the messy part:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;PendingTasksDialog(
  onViewPendingTasks: () {
    final event = findPendingTaskEvent();
    Navigator.of(dialogContext).pop();

    if (!mounted) return;

    if (event == null) {
      openCalendarScreen();
      return;
    }

    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) =&amp;gt; EventDetailsScreen(event: event),
      ),
    );
  },
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That made the dialog more reusable and the navigation more honest.&lt;/p&gt;
&lt;p&gt;I also had to make sure the mapped event list was cached alongside the rest of the home state. Otherwise the behavior would work after a fresh fetch but fail or degrade after cached dashboard state restored. This is exactly the kind of small state detail that does not announce itself loudly. It just becomes a weird “works sometimes” bug later.&lt;/p&gt;
&lt;p&gt;I do not enjoy future-me debugging “works sometimes.” Future-me is busy enough.&lt;/p&gt;
&lt;h3 id=&quot;the-button-we-left-alone&quot;&gt;The button we left alone&lt;/h3&gt;
&lt;p&gt;The most interesting decision might have been the one where we did nothing.&lt;/p&gt;
&lt;p&gt;There was a “One step closer” dialog shown after the user completed more tasks. The button said “Keep Planning.” At first, it looked like another candidate for navigation.&lt;/p&gt;
&lt;p&gt;But after thinking through it, dismissing the dialog actually made sense.&lt;/p&gt;
&lt;p&gt;That dialog is not saying “go inspect this exact object.” It is more like a little celebration. The user has made progress; the app is acknowledging it; closing the dialog returns them to whatever planning context they were already in.&lt;/p&gt;
&lt;p&gt;Changing that button to navigate somewhere might have made the app feel jumpy. Useful is good. Over-eager is not.&lt;/p&gt;
&lt;p&gt;This was a nice reminder that consistency does not mean every button does the same category of thing. It means every button does what its label and context imply.&lt;/p&gt;
&lt;h3 id=&quot;small-flutter-details-that-mattered&quot;&gt;Small Flutter details that mattered&lt;/h3&gt;
&lt;p&gt;A few practical details stood out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Navigator.pop&lt;/code&gt; should close the dialog before pushing the next screen.&lt;/li&gt;
&lt;li&gt;Use the dialog’s context to pop the dialog route.&lt;/li&gt;
&lt;li&gt;Use the parent screen’s context to push the app route.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;mounted&lt;/code&gt; before navigating after state-dependent callbacks.&lt;/li&gt;
&lt;li&gt;Keep UI widgets dumb when the state lives somewhere else.&lt;/li&gt;
&lt;li&gt;Cache all derived state needed by a callback, not only what the UI visibly renders.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of those are groundbreaking. They are just the little edges that separate “it works in my test tap” from “this behaves reliably in the app.”&lt;/p&gt;
&lt;h3 id=&quot;the-quiet-lesson&quot;&gt;The quiet lesson&lt;/h3&gt;
&lt;p&gt;Today’s work started with dialog buttons and ended up being about intent.&lt;/p&gt;
&lt;p&gt;A button label is a contract. If it says “View Notifications,” it should not merely close. If it says “View Pending Tasks,” it should make a reasonable decision about which task needs attention. If it says “Keep Planning,” sometimes the best behavior is simply to get out of the way.&lt;/p&gt;
&lt;p&gt;The technical implementation was not huge: a couple of callbacks, a mapped event list, a task selection helper, and a fallback route. But the thinking behind it was the useful part.&lt;/p&gt;
&lt;p&gt;Flutter gives us the tools to wire screens together very quickly. The harder part is deciding where the knowledge should live, what the user expects, and how much logic a tiny button deserves.&lt;/p&gt;
&lt;p&gt;Today, one tiny button deserved more than &lt;code&gt;Navigator.pop(context)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another one did not.&lt;/p&gt;
&lt;p&gt;That balance was the actual feature.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16983/when-a-button-should-do-more-than-close</guid>
<pubDate>Thu, 07 May 2026 16:33:53 +0000</pubDate>
</item>
<item>
<title>Why Your Flutter UI Freezes: Understanding Async, Await, Compute &amp; Isolates</title>
<link>https://coderlegion.com/16956/why-your-flutter-ui-freezes-understanding-async-await-compute-isolates</link>
<description>&lt;p&gt;You're building a Flutter app. Everything feels smooth — taps register instantly, animations glide, scrolling is butter.&lt;/p&gt;
&lt;p&gt;Then one day you load a large dataset. Maybe 15,000 transactions from a crypto API. Maybe a chunky image processing job. Maybe just a fat JSON response.&lt;/p&gt;
&lt;p&gt;And suddenly: &lt;strong&gt;the UI freezes&lt;/strong&gt;. Buttons stop responding. Animations stutter. Scrolling locks up. Users notice.&lt;/p&gt;
&lt;p&gt;Your first instinct? Blame &lt;code&gt;async/await&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Bad news: &lt;code&gt;async/await&lt;/code&gt; is innocent. The real culprit is something more fundamental — and once you understand it, you'll never guess at Flutter performance problems again. You'll &lt;em&gt;engineer&lt;/em&gt; around them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-uncomfortable-truth-dart-is-single-threaded&quot;&gt;The Uncomfortable Truth: Dart Is Single-Threaded&lt;/h3&gt;
&lt;p&gt;Every Flutter app runs inside a &lt;strong&gt;Dart isolate&lt;/strong&gt;. That isolate has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Its own memory&lt;/li&gt;
&lt;li&gt;Its own event loop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One execution thread&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One. Single. Thread.&lt;/p&gt;
&lt;p&gt;Picture it as a conveyor belt:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;→ RENDER FRAME → HANDLE TAP → RENDER FRAME → YOUR HEAVY WORK → RENDER FRAME →
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The belt moves forward only when each item finishes. If &quot;YOUR HEAVY WORK&quot; takes 3 seconds, the belt stops for 3 seconds. No rendering. No touch events. Nothing.&lt;/p&gt;
&lt;p&gt;That's your UI freeze. That's jank.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-biggest-misconception-in-flutter-development&quot;&gt;The Biggest Misconception in Flutter Development&lt;/h3&gt;
&lt;p&gt;Here's what most developers assume:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;await heavyCalculation(); // &quot;this runs in the background, right?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Wrong.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;async/await&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; move work to another thread. It does not create parallelism. It does not make your code run somewhere else.&lt;/p&gt;
&lt;p&gt;What it &lt;em&gt;actually&lt;/em&gt; means is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;&quot;Pause this function here. Resume it later when the Future completes.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's it. That's the whole trick.&lt;/p&gt;
&lt;p&gt;To prove it — this code freezes your UI completely, even with &lt;code&gt;async/await&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; freezeUI() async {
  for (var i = 0; i &amp;lt; 1000000000; i++) {} // CPU grinding on the UI thread
}

// Calling this:
await freezeUI(); // Still freezes. async didn't save you.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The work is still running on the &lt;strong&gt;same isolate&lt;/strong&gt; as your UI. &lt;code&gt;await&lt;/code&gt; just made it look like it wouldn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;so-what-does-async-await-actually-do-well-&quot;&gt;So What Does Async-Await Actually Do Well?&lt;/h3&gt;
&lt;p&gt;Async shines when your code is &lt;strong&gt;waiting&lt;/strong&gt;, not &lt;strong&gt;working&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When you make a network request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final response = await http.get(Uri.parse('https://api.example.com/data'));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what actually happens under the hood:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Dart initiates the request&lt;/li&gt;
&lt;li&gt;Dart tells the OS: &lt;em&gt;&quot;ping me when this finishes&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The isolate is now free&lt;/strong&gt; — it goes back to rendering, handling taps, running animations&lt;/li&gt;
&lt;li&gt;When the response arrives, Dart resumes your function from where it left off&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No blocking. No freezing. The isolate was idle &lt;em&gt;anyway&lt;/em&gt; — it was waiting on the network, not computing.&lt;/p&gt;
&lt;p&gt;This is non-blocking I/O. And &lt;code&gt;async/await&lt;/code&gt; handles it beautifully.&lt;/p&gt;
&lt;p&gt;The problem only starts when you stop &lt;em&gt;waiting&lt;/em&gt; and start &lt;em&gt;working&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-dart-event-loop-and-why-it-matters-&quot;&gt;The Dart Event Loop (And Why It Matters)&lt;/h3&gt;
&lt;p&gt;To really understand async behavior, you need to know how Dart schedules work.&lt;/p&gt;
&lt;p&gt;Dart's event loop has two queues:&lt;/p&gt;
&lt;h4 id=&quot;microtask-queue-high-priority-&quot;&gt;Microtask Queue (High Priority)&lt;/h4&gt;
&lt;p&gt;Used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Future&lt;/code&gt; continuations (what runs after an &lt;code&gt;await&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scheduleMicrotask()&lt;/code&gt; calls&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;event-queue-normal-priority-&quot;&gt;Event Queue (Normal Priority)&lt;/h4&gt;
&lt;p&gt;Used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Timer callbacks&lt;/li&gt;
&lt;li&gt;Network/IO completions&lt;/li&gt;
&lt;li&gt;Gesture events&lt;/li&gt;
&lt;li&gt;Platform messages&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The loop runs like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Run all synchronous code
2. Drain every pending microtask
3. Execute one event from the event queue
4. Repeat forever
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ordering has a real-world consequence. Here's a classic puzzle:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void main() {
  print(&quot;A&quot;);                                    // sync
  Future(() =&amp;gt; print(&quot;B&quot;));                      // event queue
  scheduleMicrotask(() =&amp;gt; print(&quot;C&quot;));           // microtask queue
  print(&quot;D&quot;);                                    // sync
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A
D
C
B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why? Because:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;A&lt;/code&gt; and &lt;code&gt;D&lt;/code&gt; are synchronous — they run first, in order&lt;/li&gt;
&lt;li&gt;&lt;code&gt;C&lt;/code&gt; is a microtask — it runs before any events&lt;/li&gt;
&lt;li&gt;&lt;code&gt;B&lt;/code&gt; is in the event queue — it runs last&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Knowing this helps you reason about execution order, debug subtle timing bugs, and understand &lt;em&gt;why&lt;/em&gt; &lt;code&gt;async/await&lt;/code&gt; resumptions feel immediate (they're microtasks).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-json-parsing-trap&quot;&gt;The JSON Parsing Trap&lt;/h3&gt;
&lt;p&gt;Here's where developers get burned all the time.&lt;/p&gt;
&lt;p&gt;Your app fetches data. The network call is fine — async, non-blocking, smooth. But then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final response = await http.get(url);               // non-blocking, isolate is free
final data = jsonDecode(response.body);             // CPU-heavy, blocks the isolate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;jsonDecode&lt;/code&gt; on a small payload? No problem. On 15,000 transaction records? That single line can block your UI for &lt;strong&gt;2–4 seconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The network call wasn't your enemy. The parsing was.&lt;/p&gt;
&lt;p&gt;This is the trap: &lt;strong&gt;the operation after &lt;code&gt;await&lt;/code&gt; can still freeze your UI&lt;/strong&gt; if it's CPU-intensive.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fix-isolates&quot;&gt;The Fix: Isolates&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;isolate&lt;/strong&gt; is an independent Dart execution environment. It has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Its own memory heap (completely separate from your UI isolate)&lt;/li&gt;
&lt;li&gt;Its own event loop&lt;/li&gt;
&lt;li&gt;Its own thread&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unlike OS threads, isolates &lt;strong&gt;cannot share memory&lt;/strong&gt;. This eliminates entire classes of bugs — no race conditions, no mutex deadlocks, no shared-state corruption. Instead, isolates communicate by passing messages.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Main Isolate                    Worker Isolate
─────────────                   ──────────────
UI rendering                    JSON parsing
Touch handling       ←──────→   Image processing
Animations          (messages)  Heavy computation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While the worker isolate grinds through CPU work, your UI isolate keeps rendering at 60fps.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;compute-isolates-without-the-ceremony&quot;&gt;&lt;code&gt;compute()&lt;/code&gt;: Isolates Without the Ceremony&lt;/h3&gt;
&lt;p&gt;Spawning a raw isolate involves some boilerplate. For one-off tasks, Flutter gives you &lt;code&gt;compute()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Before: freezes UI
final transactions = jsonDecode(response.body);

// After: runs in a separate isolate, UI stays smooth
final transactions = await compute(parseTransactions, response.body);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;code&gt;parseTransactions&lt;/code&gt; is a top-level or static function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;List&amp;lt;Transaction&amp;gt; parseTransactions(String rawJson) {
  final decoded = jsonDecode(rawJson) as List;
  return decoded.map((e) =&amp;gt; Transaction.fromJson(e)).toList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;compute()&lt;/code&gt; under the hood:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Spawns a new isolate&lt;/li&gt;
&lt;li&gt;Sends your data to it (via message passing — it gets copied)&lt;/li&gt;
&lt;li&gt;Runs your function&lt;/li&gt;
&lt;li&gt;Returns the result to the main isolate&lt;/li&gt;
&lt;li&gt;Destroys the worker isolate&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One function call. UI stays responsive. Task done in parallel.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-compute-isn-t-enough-persistent-isolates&quot;&gt;When &lt;code&gt;compute()&lt;/code&gt; Isn't Enough: Persistent Isolates&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;compute()&lt;/code&gt; is great for one-shot jobs. But spawning and destroying isolates has overhead. If you're doing &lt;strong&gt;continuous background work&lt;/strong&gt; — like a live transaction feed, streaming parser, or on-device ML inference — you want a persistent isolate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Pseudo-code for a long-running isolate
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(backgroundWorker, receivePort.sendPort);

receivePort.listen((message) {
  // Handle result from background isolate
  setState(() =&amp;gt; _data = message);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use persistent isolates when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You're processing a continuous stream of data&lt;/li&gt;
&lt;li&gt;You're running local AI/ML models&lt;/li&gt;
&lt;li&gt;You have a background sync engine&lt;/li&gt;
&lt;li&gt;The spawning overhead of &lt;code&gt;compute()&lt;/code&gt; becomes measurable&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-decision-framework&quot;&gt;The Decision Framework&lt;/h3&gt;
&lt;p&gt;Every time you write async code, ask: &lt;strong&gt;am I waiting or working?&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;API call / network request &lt;/td&gt;&lt;td&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database read/write &lt;/td&gt;&lt;td&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;File I/O &lt;/td&gt;&lt;td&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timer / delay &lt;/td&gt;&lt;td&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JSON parsing (large) &lt;/td&gt;&lt;td&gt;&lt;code&gt;compute()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Image processing &lt;/td&gt;&lt;td&gt;&lt;code&gt;compute()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Encryption / hashing &lt;/td&gt;&lt;td&gt;&lt;code&gt;compute()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;One-off heavy computation &lt;/td&gt;&lt;td&gt;&lt;code&gt;compute()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Streaming data parser &lt;/td&gt;&lt;td&gt;Persistent Isolate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;On-device ML inference &lt;/td&gt;&lt;td&gt;Persistent Isolate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Background sync engine &lt;/td&gt;&lt;td&gt;Persistent Isolate&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-example-crypto-wallet-transaction-feed&quot;&gt;Real-World Example: Crypto Wallet Transaction Feed&lt;/h3&gt;
&lt;p&gt;Let's put it all together with a realistic scenario.&lt;/p&gt;
&lt;p&gt;Your wallet app fetches 15,000 transactions. Naïve implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Freezes UI for multiple seconds
Future&amp;lt;void&amp;gt; loadTransactions() async {
  final response = await http.get(transactionsUrl);   // fine
  final txs = jsonDecode(response.body);              //  blocks here
  setState(() =&amp;gt; _transactions = txs);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Better:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// UI stays responsive throughout
Future&amp;lt;void&amp;gt; loadTransactions() async {
  final response = await http.get(transactionsUrl);                    // non-blocking
  final txs = await compute(parseTransactions, response.body);         // off-thread
  setState(() =&amp;gt; _transactions = txs);                                 // back on UI thread
}

List&amp;lt;Transaction&amp;gt; parseTransactions(String json) {
  return (jsonDecode(json) as List)
      .map((e) =&amp;gt; Transaction.fromJson(e))
      .toList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The user never sees a freeze. Scrolling stays smooth at 60fps while thousands of transactions parse in the background.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;common-myths-killed&quot;&gt;Common Myths, Killed&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&quot;Async/await makes my code multithreaded&quot;&lt;/strong&gt;&lt;br&gt;
No. It handles asynchronous &lt;em&gt;waiting&lt;/em&gt;. Multithreading requires isolates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;If I put await before it, it won't block the UI&quot;&lt;/strong&gt;&lt;br&gt;
Only if the work is I/O-bound. CPU-heavy code blocks regardless of &lt;code&gt;await&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;compute() is some kind of magic optimization&quot;&lt;/strong&gt;&lt;br&gt;
It's just a thin wrapper around isolate spawning. The magic is the isolate, not the function.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Dart can only use one CPU core&quot;&lt;/strong&gt;&lt;br&gt;
False. Each isolate can run on its own OS thread. Dart can use all your cores through isolates.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mental-model-that-fixes-everything&quot;&gt;The Mental Model That Fixes Everything&lt;/h3&gt;
&lt;p&gt;Burn this into your brain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async/await  →  for WAITING
isolates     →  for WORKING
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the whole framework. Every Flutter performance bug related to async ultimately comes down to confusing these two. Once you stop conflating them, you stop guessing — and start building apps that are genuinely fast.&lt;/p&gt;
&lt;p&gt;Your UI thread should &lt;em&gt;only&lt;/em&gt; wait. Never grind.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16956/why-your-flutter-ui-freezes-understanding-async-await-compute-isolates</guid>
<pubDate>Thu, 07 May 2026 11:28:09 +0000</pubDate>
</item>
<item>
<title>Everything about Stateful &amp; Stateless widgets in Flutter</title>
<link>https://coderlegion.com/16550/everything-about-stateful-stateless-widgets-in-flutter</link>
<description>&lt;p&gt;Flutter's widget system looks deceptively simple on the surface — everything is a widget, you compose them, and the UI appears. But once you go deeper, you realize Flutter made a very deliberate architectural decision by splitting widgets into two kinds: &lt;strong&gt;Stateless&lt;/strong&gt; and &lt;strong&gt;Stateful&lt;/strong&gt;. Understanding &lt;em&gt;why&lt;/em&gt; that split exists, and &lt;em&gt;how&lt;/em&gt; the machinery behind it works, is what separates developers who use Flutter from developers who truly understand it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-a-widget-really-&quot;&gt;What Is a Widget, Really?&lt;/h3&gt;
&lt;p&gt;Before distinguishing the two, it's worth being precise about what a widget actually is in Flutter's model.&lt;/p&gt;
&lt;p&gt;A widget is &lt;strong&gt;an immutable description of a piece of UI&lt;/strong&gt;. That's it. It's a configuration object — a blueprint. It doesn't draw anything, it doesn't hold layout state, it doesn't live on screen. It just describes what should exist.&lt;/p&gt;
&lt;p&gt;Flutter maintains three separate trees to actually make that description come alive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Widget tree&lt;/strong&gt; — The lightweight, immutable descriptions you write in code. Rebuilt frequently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Element tree&lt;/strong&gt; — The long-lived, mutable counterpart to each widget. Elements persist across rebuilds and act as the glue between widgets and render objects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RenderObject tree&lt;/strong&gt; — The heavy, layout-and-paint layer. This is what actually measures, positions, and renders pixels.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This separation is critical to Flutter's performance. Rebuilding the widget tree is cheap because widgets are just Dart objects with a few fields. The element and render object trees, which are expensive to create, are preserved and updated incrementally.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;stateless-widgets&quot;&gt;Stateless Widgets&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;StatelessWidget&lt;/code&gt; is a widget whose entire output is determined solely by its constructor arguments. Given the same inputs, it will always produce the same widget tree — there is no internal state that can change between builds.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class GreetingCard extends StatelessWidget {
  final String name;
  final String message;

  const GreetingCard({super.key, required this.name, required this.message});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Hello, $name'),
        Text(message),
      ],
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flutter calls &lt;code&gt;build()&lt;/code&gt; on a stateless widget when its parent rebuilds and passes it new configuration. The widget itself has no lifecycle beyond that single &lt;code&gt;build()&lt;/code&gt; call — no initialization, no teardown, no reactive hooks.&lt;/p&gt;
&lt;h4 id=&quot;the-const-optimization&quot;&gt;The &lt;code&gt;const&lt;/code&gt; Optimization&lt;/h4&gt;
&lt;p&gt;Stateless widgets shine when paired with &lt;code&gt;const&lt;/code&gt; constructors. When Flutter encounters a &lt;code&gt;const&lt;/code&gt; widget in a rebuild, it can short-circuit the entire subtree — it knows the widget is identical to the previous one (same type, same arguments, canonicalized at compile time), so there's nothing to diff.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Flutter will never rebuild this subtree unless the parent explicitly changes it
const Icon(Icons.star, color: Colors.amber, size: 24);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For UI elements that are truly static — icons, labels, decorative containers — &lt;code&gt;const&lt;/code&gt; is a free performance win. Make it a habit to use &lt;code&gt;const&lt;/code&gt; wherever possible and let the Dart analyzer enforce it with the &lt;code&gt;prefer_const_constructors&lt;/code&gt; lint.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;stateful-widgets&quot;&gt;Stateful Widgets&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;StatefulWidget&lt;/code&gt; is for UI that needs to change over time based on user interaction, async results, animations, or any other runtime event. The key design decision Flutter made here is that &lt;strong&gt;the widget and its state are two separate objects&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State&amp;lt;Counter&amp;gt; createState() =&amp;gt; _CounterState();
}

class _CounterState extends State&amp;lt;Counter&amp;gt; {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () =&amp;gt; setState(() =&amp;gt; _count++),
      child: Text('Count: $_count'),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why separate them? Because &lt;strong&gt;widgets are immutable and short-lived&lt;/strong&gt;, but &lt;strong&gt;state needs to persist&lt;/strong&gt;. When Flutter rebuilds the &lt;code&gt;Counter&lt;/code&gt; widget, it creates a brand-new &lt;code&gt;Counter&lt;/code&gt; object — but the &lt;code&gt;_CounterState&lt;/code&gt; instance that holds &lt;code&gt;_count&lt;/code&gt; is kept alive by the element tree. The element simply calls &lt;code&gt;widget.build()&lt;/code&gt; on the new widget description while retaining the existing &lt;code&gt;State&lt;/code&gt; object beneath it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-stateful-lifecycle&quot;&gt;The Stateful Lifecycle&lt;/h3&gt;
&lt;p&gt;State objects have a full lifecycle managed by the Flutter framework. Understanding each phase is essential for writing correct, leak-free Flutter code.&lt;/p&gt;
&lt;h4 id=&quot;createstate-&quot;&gt;&lt;code&gt;createState()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Called on the widget (not the state) to create the associated &lt;code&gt;State&lt;/code&gt; object. This happens exactly once — when the widget is first inserted into the tree.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
State&amp;lt;MyWidget&amp;gt; createState() =&amp;gt; _MyWidgetState();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;initstate-&quot;&gt;&lt;code&gt;initState()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The first method called on the &lt;code&gt;State&lt;/code&gt; object after it's created. This is where you perform one-time initialization — subscribing to streams, setting up animation controllers, making initial API calls.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
void initState() {
  super.initState(); // Always call super first
  _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
  _fetchInitialData();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;initState()&lt;/code&gt; runs before the first build, and &lt;code&gt;context&lt;/code&gt; is available but restricted here — you cannot call &lt;code&gt;context&lt;/code&gt;-dependent operations like &lt;code&gt;Navigator.of(context)&lt;/code&gt; from &lt;code&gt;initState()&lt;/code&gt;. Use &lt;code&gt;didChangeDependencies()&lt;/code&gt; or a post-frame callback for that.&lt;/p&gt;
&lt;h4 id=&quot;didchangedependencies-&quot;&gt;&lt;code&gt;didChangeDependencies()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Called immediately after &lt;code&gt;initState()&lt;/code&gt;, and again whenever an &lt;code&gt;InheritedWidget&lt;/code&gt; that this state object depends on changes. For example, if your widget reads from &lt;code&gt;Theme.of(context)&lt;/code&gt; or a custom &lt;code&gt;InheritedWidget&lt;/code&gt;, and that data changes, &lt;code&gt;didChangeDependencies()&lt;/code&gt; fires before the next build.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
void didChangeDependencies() {
  super.didChangeDependencies();
  // Safe to use context-dependent APIs here
  final locale = Localizations.localeOf(context);
  _updateForLocale(locale);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;build-&quot;&gt;&lt;code&gt;build()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The only required override. Called every time the state is marked dirty — after &lt;code&gt;setState()&lt;/code&gt;, after &lt;code&gt;didChangeDependencies()&lt;/code&gt;, after &lt;code&gt;didUpdateWidget()&lt;/code&gt;, and during the initial render. Must be pure and free of side effects.&lt;/p&gt;
&lt;h4 id=&quot;setstate-&quot;&gt;&lt;code&gt;setState()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The mechanism for signaling that internal state has changed and the widget needs to rebuild.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;setState(() {
  _count++;
  _isLoading = false;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you call &lt;code&gt;setState()&lt;/code&gt;, Flutter marks the element as &lt;strong&gt;dirty&lt;/strong&gt;. It doesn't trigger an immediate rebuild — instead, it schedules a frame. At the start of the next frame, Flutter walks the element tree, finds all dirty elements, and calls &lt;code&gt;build()&lt;/code&gt; on each of them. The widget returned by &lt;code&gt;build()&lt;/code&gt; is then reconciled against the previous widget using the element tree (more on this below).&lt;/p&gt;
&lt;h4 id=&quot;didupdatewidget-&quot;&gt;&lt;code&gt;didUpdateWidget()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Called when the &lt;em&gt;parent&lt;/em&gt; rebuilds and passes a new widget instance to this state's element. The old widget is passed as an argument, giving you a chance to react to configuration changes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
void didUpdateWidget(covariant MyWidget oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.userId != oldWidget.userId) {
    _fetchDataForUser(widget.userId);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the correct place to respond to external prop changes — not &lt;code&gt;initState()&lt;/code&gt;, which only runs once.&lt;/p&gt;
&lt;h4 id=&quot;deactivate-&quot;&gt;&lt;code&gt;deactivate()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Called when the &lt;code&gt;State&lt;/code&gt; object is temporarily removed from the tree — for example, when a widget is moved to a different subtree location using a &lt;code&gt;GlobalKey&lt;/code&gt;. This is uncommon in everyday Flutter code, but important to know.&lt;/p&gt;
&lt;h4 id=&quot;dispose-&quot;&gt;&lt;code&gt;dispose()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Called when the &lt;code&gt;State&lt;/code&gt; object is permanently removed from the tree. This is where you clean up resources — cancel timers, close streams, dispose animation controllers, remove listeners.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
void dispose() {
  _controller.dispose();
  _scrollController.dispose();
  _subscription.cancel();
  super.dispose(); // Always call super last
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Forgetting to &lt;code&gt;dispose()&lt;/code&gt; resources is one of the most common sources of memory leaks in Flutter apps.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-reconciliation-actually-works&quot;&gt;How Reconciliation Actually Works&lt;/h3&gt;
&lt;p&gt;When a dirty element rebuilds, it gets a new widget from &lt;code&gt;build()&lt;/code&gt;. Flutter then needs to decide: does the existing child element still apply, or does it need to be replaced?&lt;/p&gt;
&lt;p&gt;Flutter checks two things in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Type&lt;/strong&gt; — Is the new widget the same runtime type as the widget the element currently holds?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key&lt;/strong&gt; — If a key is present, does it match?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If both match, Flutter &lt;strong&gt;reuses the element&lt;/strong&gt; and simply updates its widget reference. The underlying &lt;code&gt;RenderObject&lt;/code&gt; is updated in place — expensive layout state is preserved.&lt;/p&gt;
&lt;p&gt;If either differs, Flutter &lt;strong&gt;deactivates the old element&lt;/strong&gt;, creates a new one, and mounts it. For stateful widgets, this means the &lt;code&gt;State&lt;/code&gt; is disposed and a new one is created from scratch.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Scenario: parent conditionally renders one of two widgets
if (_showA)
  const WidgetA()   // Type: WidgetA
else
  const WidgetB()   // Type: WidgetB — new element, old state discarded
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;keys-making-reconciliation-precise&quot;&gt;Keys: Making Reconciliation Precise&lt;/h3&gt;
&lt;p&gt;By default, Flutter reconciles children by &lt;strong&gt;type and position&lt;/strong&gt;. For static, fixed-size lists this works perfectly. But for &lt;strong&gt;dynamic, reorderable, or filtered lists&lt;/strong&gt;, positional matching breaks down.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Imagine these items get reordered from [A, B] to [B, A]
// Without keys, Flutter sees: position 0 = same type, position 1 = same type
// It reuses the same elements — the state of A bleeds into B's position
Column(
  children: [
    TodoItem(label: 'B'),
    TodoItem(label: 'A'),
  ],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keys tell Flutter explicitly which element corresponds to which widget, regardless of position.&lt;/p&gt;
&lt;h4 id=&quot;localkey&quot;&gt;&lt;code&gt;LocalKey&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Scoped to siblings within the same parent. The common variants are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ValueKey&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/strong&gt; — keyed by a value (most common, use with IDs or strings)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ObjectKey&lt;/code&gt;&lt;/strong&gt; — keyed by object identity&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;UniqueKey&lt;/code&gt;&lt;/strong&gt; — generates a new key every time (forces recreation — use deliberately)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ListView(
  children: _todos.map((todo) =&amp;gt;
    TodoItem(key: ValueKey(todo.id), label: todo.label)
  ).toList(),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when items reorder, Flutter correctly matches each &lt;code&gt;TodoItem&lt;/code&gt; to its element by ID, preserving the right state in the right place.&lt;/p&gt;
&lt;h4 id=&quot;globalkey&quot;&gt;&lt;code&gt;GlobalKey&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Unique across the entire widget tree. A &lt;code&gt;GlobalKey&lt;/code&gt; lets you access a widget's &lt;code&gt;State&lt;/code&gt;, &lt;code&gt;BuildContext&lt;/code&gt;, or &lt;code&gt;RenderObject&lt;/code&gt; from anywhere in the app.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final _formKey = GlobalKey&amp;lt;FormState&amp;gt;();

// Later:
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GlobalKey&lt;/code&gt; is powerful but expensive — Flutter maintains a global registry for them. Avoid using them as a shortcut to reach into widget state; prefer proper state management patterns for that. Use &lt;code&gt;GlobalKey&lt;/code&gt; when you genuinely need framework-level access — form validation, imperative scroll control, or moving a subtree across the tree while preserving its state.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mounted-property-and-async-safety&quot;&gt;The &lt;code&gt;mounted&lt;/code&gt; Property and Async Safety&lt;/h3&gt;
&lt;p&gt;Every &lt;code&gt;State&lt;/code&gt; object has a &lt;code&gt;mounted&lt;/code&gt; boolean. It becomes &lt;code&gt;true&lt;/code&gt; after &lt;code&gt;initState()&lt;/code&gt; runs and transitions to &lt;code&gt;false&lt;/code&gt; after &lt;code&gt;dispose()&lt;/code&gt;. It exists to solve a specific and common problem: &lt;strong&gt;calling &lt;code&gt;setState()&lt;/code&gt; after the widget has been removed from the tree&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; _loadData() async {
  final result = await repository.fetchItems(); // async gap — widget may be disposed here

  setState(() =&amp;gt; _items = result); //  throws if widget was already disposed
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The safe pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; _loadData() async {
  final result = await repository.fetchItems();

  if (mounted) {
    setState(() =&amp;gt; _items = result);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is especially important in scenarios where a user navigates away while a network call is still in flight, or when a parent removes the widget before the async operation completes. The &lt;code&gt;mounted&lt;/code&gt; check is a lightweight guard that prevents both the exception and the logical error of mutating dead state.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-to-use-which&quot;&gt;When to Use Which&lt;/h3&gt;
&lt;p&gt;The choice is usually straightforward once you internalize the rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;If the widget can fully describe its UI from its constructor arguments alone, use &lt;code&gt;StatelessWidget&lt;/code&gt;. If it needs to track any internal state that changes over time, use &lt;code&gt;StatefulWidget&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Some practical heuristics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pure display widgets (cards, labels, icons, decorative layouts) → &lt;code&gt;StatelessWidget&lt;/code&gt; with &lt;code&gt;const&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Anything with user interaction that changes appearance (toggles, counters, form fields) → &lt;code&gt;StatefulWidget&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Animations → &lt;code&gt;StatefulWidget&lt;/code&gt; with &lt;code&gt;SingleTickerProviderStateMixin&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Widgets that only receive state from above (via constructor or &lt;code&gt;InheritedWidget&lt;/code&gt;) and just render it → &lt;code&gt;StatelessWidget&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's also worth noting that the separation isn't just about correctness — it's about communicating intent. A &lt;code&gt;StatelessWidget&lt;/code&gt; signals to every reader of your code: &lt;em&gt;this widget has no internal moving parts&lt;/em&gt;. That's a strong, useful guarantee.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;putting-it-all-together&quot;&gt;Putting It All Together&lt;/h3&gt;
&lt;p&gt;Flutter's architecture makes more sense when you see the whole picture at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Widgets&lt;/strong&gt; are cheap, immutable blueprints. Rebuilt frequently, thrown away without ceremony.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elements&lt;/strong&gt; are the persistent, mutable objects that hold the tree together. They match incoming widgets to existing state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RenderObjects&lt;/strong&gt; are expensive and preserved. Updated in place whenever possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stateful widgets&lt;/strong&gt; get a separate &lt;code&gt;State&lt;/code&gt; object, anchored to an element, that survives widget rebuilds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;setState()&lt;/code&gt;&lt;/strong&gt; marks elements dirty and schedules a frame — not an immediate rebuild.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconciliation&lt;/strong&gt; by type and key determines whether to reuse or replace elements.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keys&lt;/strong&gt; make that reconciliation correct when position alone isn't enough.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mounted&lt;/code&gt;&lt;/strong&gt; is your safety net for async operations that outlive the widget.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every Flutter performance tip — minimizing rebuilds, using &lt;code&gt;const&lt;/code&gt;, lifting state appropriately, keying lists — traces back to understanding these fundamentals. Once the model is clear, the best practices follow naturally.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16550/everything-about-stateful-stateless-widgets-in-flutter</guid>
<pubDate>Mon, 04 May 2026 03:34:50 +0000</pubDate>
</item>
<item>
<title>The Selfie Was Fine. The Pixels Were Sideways.</title>
<link>https://coderlegion.com/16339/the-selfie-was-fine-the-pixels-were-sideways</link>
<description>&lt;p&gt;I spent part of today staring at a selfie that looked completely normal in the app until it became a profile photo.&lt;/p&gt;
&lt;p&gt;That sentence sounds harmless. It was not harmless.&lt;/p&gt;
&lt;p&gt;The face verification flow looked fine while running. The camera preview was upright. The liveness checks passed. The user smiled, turned left, turned right, looked up, looked down, and the app happily uploaded the images. Then the first image, the nice centered smiling one, became the user's display picture.&lt;/p&gt;
&lt;p&gt;And suddenly the user was lying sideways.&lt;/p&gt;
&lt;p&gt;Head on the right. Neck on the left. The kind of bug where the UI looks innocent, the backend looks innocent, and the image itself is quietly doing the wrong thing.&lt;/p&gt;
&lt;h3 id=&quot;the-suspicious-part-was-not-the-avatar&quot;&gt;The suspicious part was not the avatar&lt;/h3&gt;
&lt;p&gt;My first instinct was to look at the profile image rendering. Maybe the avatar widget was doing something odd. Maybe the network image package ignored metadata. Maybe the backend transformed the image. Maybe some storage layer stripped EXIF orientation.&lt;/p&gt;
&lt;p&gt;But the avatar was boring in the best possible way. It received a URL and rendered it with &lt;code&gt;BoxFit.cover&lt;/code&gt;. No transforms. No rotation. No custom painting. Nothing dramatic.&lt;/p&gt;
&lt;p&gt;The face verification flow was more interesting.&lt;/p&gt;
&lt;p&gt;The app was using the camera stream for liveness detection. Each frame went through ML Kit face detection, and once a rule was stable enough, we captured the image for that rule. The first rule was the centered smile, which later became the display picture.&lt;/p&gt;
&lt;p&gt;That gave the bug a shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;preview upright&lt;/li&gt;
&lt;li&gt;ML Kit detection working&lt;/li&gt;
&lt;li&gt;saved/uploaded image rotated&lt;/li&gt;
&lt;li&gt;all captured images rotated the same way&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That combination usually means the display layer and detection layer know about orientation, but the saved pixels do not.&lt;/p&gt;
&lt;h3 id=&quot;the-camera-stream-was-telling-the-truth-just-not-the-truth-i-wanted&quot;&gt;The camera stream was telling the truth, just not the truth I wanted&lt;/h3&gt;
&lt;p&gt;The important detail was that the capture path was not using &lt;code&gt;takePicture()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Instead, the flow converted the exact &lt;code&gt;CameraImage&lt;/code&gt; stream frame into a PNG. The motivation made sense: use the precise frame that satisfied the face rule. No waiting for the camera to stop streaming, no second capture after the user moved slightly, no autofocus/flicker delay. It is a tempting approach, and honestly, I still like the intent.&lt;/p&gt;
&lt;p&gt;The rough shape looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final imagePath = await _captureFromStreamFrame(streamFrame);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside that helper, Android NV21 bytes were converted into BGRA pixels, then encoded as a PNG:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ui.decodeImageFromPixels(
  bgraBytes,
  image.width,
  image.height,
  ui.PixelFormat.bgra8888,
  (img) =&amp;gt; completer.complete(img),
);

final uiImage = await completer.future;
final byteData = await uiImage.toByteData(
  format: ui.ImageByteFormat.png,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is where Flutter politely allowed me to shoot myself in the foot.&lt;/p&gt;
&lt;p&gt;Raw camera stream frames are not necessarily in the same orientation as the preview on screen. On Android especially, the camera sensor often has a native orientation that is effectively landscape relative to the way the user holds the phone. The preview widget handles that. ML Kit also gets rotation metadata, so detection can still work.&lt;/p&gt;
&lt;p&gt;But if you manually turn the raw bytes into a PNG, nobody magically rotates those pixels for you.&lt;/p&gt;
&lt;p&gt;You get the sensor's version of reality.&lt;/p&gt;
&lt;p&gt;And the sensor's version of reality had my user's head rotated 90 degrees.&lt;/p&gt;
&lt;h3 id=&quot;the-part-that-made-it-slightly-annoying&quot;&gt;The part that made it slightly annoying&lt;/h3&gt;
&lt;p&gt;The obvious fix was &quot;rotate the image.&quot;&lt;/p&gt;
&lt;p&gt;The less obvious question was: rotate it which way?&lt;/p&gt;
&lt;p&gt;This is where mobile camera work always gets a little slippery. You are dealing with a few concepts that sound related but are not interchangeable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sensor orientation&lt;/li&gt;
&lt;li&gt;device orientation&lt;/li&gt;
&lt;li&gt;preview orientation&lt;/li&gt;
&lt;li&gt;front-camera mirroring&lt;/li&gt;
&lt;li&gt;saved image orientation&lt;/li&gt;
&lt;li&gt;EXIF metadata, if you are saving JPEGs&lt;/li&gt;
&lt;li&gt;actual pixel rotation, if you are writing PNGs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this flow we were writing PNGs from raw pixels, so EXIF was not going to save us. The pixels themselves needed to be corrected.&lt;/p&gt;
&lt;p&gt;The first attempt used the inverse of the camera sensor orientation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;return (360 - sensorOrientation) % 360;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was a reasonable guess. It was also wrong for the tested device.&lt;/p&gt;
&lt;p&gt;The image moved from sideways to upside down. Progress, technically. Emotionally, less so.&lt;/p&gt;
&lt;p&gt;That test was useful though, because it proved two things immediately:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;We were definitely fixing the right part of the pipeline.&lt;/li&gt;
&lt;li&gt;The rotation direction needed to be flipped.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So the final working version used the reported sensor orientation directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;int _streamFrameRotationDegrees({
  required int sensorOrientation,
}) {
  if (!Platform.isAndroid) return 0;

  return sensorOrientation % 360;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the decoded frame gets rotated before encoding:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final rotationDegrees = _streamFrameRotationDegrees(
  sensorOrientation: sensorOrientation,
);

final outputImage = rotationDegrees == 0
    ? uiImage
    : await _rotateImageClockwise(uiImage, rotationDegrees);

final byteData = await outputImage.toByteData(
  format: ui.ImageByteFormat.png,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rotation helper itself is not complicated, but it is the kind of code where one swapped width/height can ruin your afternoon:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;ui.Image&amp;gt; _rotateImageClockwise(
  ui.Image source,
  int degrees,
) async {
  final normalizedDegrees = degrees % 360;
  if (normalizedDegrees == 0) return source;

  final swapsDimensions =
      normalizedDegrees == 90 || normalizedDegrees == 270;
  final outputWidth = swapsDimensions ? source.height : source.width;
  final outputHeight = swapsDimensions ? source.width : source.height;

  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder);

  canvas.translate(outputWidth / 2, outputHeight / 2);
  canvas.rotate(normalizedDegrees * math.pi / 180);
  canvas.translate(-source.width / 2, -source.height / 2);
  canvas.drawImage(source, Offset.zero, Paint());

  final picture = recorder.endRecording();
  final rotated = await picture.toImage(outputWidth, outputHeight);
  picture.dispose();

  return rotated;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once that was in place, the saved local images finally looked like what the user saw in the camera preview: head up, neck down, nobody accidentally reclining across the avatar.&lt;/p&gt;
&lt;p&gt;A small win, but a very satisfying one.&lt;/p&gt;
&lt;h3 id=&quot;the-temporary-debugging-tool-was-worth-it&quot;&gt;The temporary debugging tool was worth it&lt;/h3&gt;
&lt;p&gt;One of the better decisions during this fix was not waiting for the entire upload-display-profile loop every time.&lt;/p&gt;
&lt;p&gt;Originally, the feedback cycle was too long:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;run face verification&lt;/li&gt;
&lt;li&gt;upload images&lt;/li&gt;
&lt;li&gt;wait for backend processing&lt;/li&gt;
&lt;li&gt;fetch user details again&lt;/li&gt;
&lt;li&gt;check the display picture&lt;/li&gt;
&lt;li&gt;wonder if the bug was in capture, upload, storage, API, cache, or rendering&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is too many suspects.&lt;/p&gt;
&lt;p&gt;So I added a temporary debug-only local preview after capture and before upload. It showed the five local files in a grid labeled &lt;code&gt;center&lt;/code&gt;, &lt;code&gt;left&lt;/code&gt;, &lt;code&gt;right&lt;/code&gt;, &lt;code&gt;up&lt;/code&gt;, and &lt;code&gt;down&lt;/code&gt;. That made the problem brutally obvious. The images were already rotated before they ever touched remote storage.&lt;/p&gt;
&lt;p&gt;That little preview screen did not survive the final cleanup, and it should not have. But as a debugging instrument it was perfect. It shortened the loop from &quot;wait for the full system&quot; to &quot;capture and inspect the exact file.&quot;&lt;/p&gt;
&lt;p&gt;I keep relearning this lesson: when a pipeline has too many stages, build a peephole into the middle.&lt;/p&gt;
&lt;p&gt;Not a permanent feature. Not a grand observability platform. Just a tiny window into the thing you keep making assumptions about.&lt;/p&gt;
&lt;h3 id=&quot;why-not-just-use-takepicture-&quot;&gt;Why not just use &lt;code&gt;takePicture()&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;This came up naturally.&lt;/p&gt;
&lt;p&gt;The signup selfie flow in another part of the app already uses &lt;code&gt;takePicture()&lt;/code&gt;. That path is simpler because the camera plugin handles more of the normal image capture behavior for you. For many apps, that is absolutely what I would prefer.&lt;/p&gt;
&lt;p&gt;The liveness flow had a different tradeoff. It wanted the exact frame that passed the rule. If the user smiles and holds still, we capture the stream frame that ML Kit just approved. That avoids a small but real gap between &quot;rule passed&quot; and &quot;photo taken.&quot;&lt;/p&gt;
&lt;p&gt;So there were two reasonable options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;switch back to &lt;code&gt;takePicture()&lt;/code&gt; and lean on the camera plugin's capture behavior&lt;/li&gt;
&lt;li&gt;keep stream-frame capture and explicitly own pixel orientation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We chose the second path because it preserved the existing liveness behavior. But choosing it means accepting the responsibility that comes with raw frames. The camera plugin is no longer your orientation babysitter at save time.&lt;/p&gt;
&lt;p&gt;That tradeoff is fine, as long as it is intentional.&lt;/p&gt;
&lt;h3 id=&quot;the-device-question&quot;&gt;The device question&lt;/h3&gt;
&lt;p&gt;After the fix worked, the next question was the right one: will this hold across devices?&lt;/p&gt;
&lt;p&gt;The answer is: better than a hardcoded 90-degree rotation, but still worth testing.&lt;/p&gt;
&lt;p&gt;Different Android devices may report different camera sensor orientations, commonly 90 or 270. Some devices may even differ between front and back cameras. Using &lt;code&gt;sensorOrientation&lt;/code&gt; is the portable part of the fix. It asks the camera what orientation it actually uses instead of assuming every phone is the one on my desk.&lt;/p&gt;
&lt;p&gt;But there are caveats.&lt;/p&gt;
&lt;p&gt;The current flow assumes portrait capture. If the app allows the user to rotate the phone into landscape during verification, then sensor orientation alone is not enough. We would need to account for current device orientation too.&lt;/p&gt;
&lt;p&gt;Also, rotation and mirroring are separate problems. A front camera preview may be mirrored for a familiar selfie experience, but that does not automatically mean the saved image should be mirrored. If logos or text appear reversed, that is a horizontal flip decision, not a rotation fix.&lt;/p&gt;
&lt;p&gt;For today's bug, the issue was rotation. The corrected saved PNG now matches the visual expectation.&lt;/p&gt;
&lt;h3 id=&quot;the-thing-i-am-taking-away&quot;&gt;The thing I am taking away&lt;/h3&gt;
&lt;p&gt;The interesting part of this bug was not that an image was rotated. That happens all the time in mobile work.&lt;/p&gt;
&lt;p&gt;The interesting part was how many layers were technically doing the right thing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Camera preview looked correct.&lt;/li&gt;
&lt;li&gt;ML Kit detection worked because it received rotation metadata.&lt;/li&gt;
&lt;li&gt;Upload worked because it uploaded exactly what it was given.&lt;/li&gt;
&lt;li&gt;Avatar rendering worked because it displayed exactly what the URL returned.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The bug lived in the one handoff where metadata stopped being enough and pixels became permanent.&lt;/p&gt;
&lt;p&gt;That is the kind of issue that makes mobile development feel like plumbing with opinions. Every layer has its own model of reality. Most days they agree. Today they did not.&lt;/p&gt;
&lt;p&gt;At least now the user's face is upright.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16339/the-selfie-was-fine-the-pixels-were-sideways</guid>
<pubDate>Fri, 01 May 2026 15:29:12 +0000</pubDate>
</item>
<item>
<title>The Day Search Wasn't Just Search</title>
<link>https://coderlegion.com/16338/the-day-search-wasnt-just-search</link>
<description>&lt;p&gt;Some tasks arrive wearing a tiny hat.&lt;/p&gt;
&lt;p&gt;Today’s was: “inspect the vendor search screen.” Simple enough. Search screen, endpoint response, pagination, a couple of screenshots. Nothing too wild.&lt;/p&gt;
&lt;p&gt;Then I looked closer and realized the UI was quietly making a promise the backend data did not support.&lt;/p&gt;
&lt;p&gt;That is usually where mobile work gets interesting. Not broken in the loud, stack-trace way. Broken in the more slippery way, where the app technically works, but the user walks away with the wrong idea.&lt;/p&gt;
&lt;h3 id=&quot;the-suspicious-price-on-request-&quot;&gt;The suspicious “Price on request”&lt;/h3&gt;
&lt;p&gt;The search endpoint was returning vendors with two different service-ish fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;services&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vendorServices&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At a glance, both sounded useful. One had pricing information. The other had names like photography, lighting, security, and catering. The UI had been built to show &lt;code&gt;services&lt;/code&gt; first, and if that list was empty, fall back to &lt;code&gt;vendorServices&lt;/code&gt; with the label “Price on request.”&lt;/p&gt;
&lt;p&gt;That fallback looked harmless. It even made the screen feel more complete.&lt;/p&gt;
&lt;p&gt;But it was wrong.&lt;/p&gt;
&lt;p&gt;The deeper distinction was this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;services&lt;/code&gt; represented actual bookable services with prices and service IDs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vendorServices&lt;/code&gt; represented capabilities or declared service categories.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That second list was not enough to create a booking. There was no real pricing model, no service ID, and no guarantee that the item was ready for a client to book. So showing “Price on request” created a nice-looking card while hiding the truth: this vendor had not added bookable services yet.&lt;/p&gt;
&lt;p&gt;The old logic was basically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;List&amp;lt;ServiceDisplay&amp;gt; resolveServices(Vendor vendor) {
  final services = vendor.services.map(mapPricedService).toList();

  if (services.isNotEmpty) return services;

  return vendor.vendorServices.map((service) {
    return ServiceDisplay(
      name: service.name,
      priceText: 'Price on request',
    );
  }).toList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The UI was trying to be helpful, but it was flattening two different concepts into one. That is one of those bugs that does not look like a bug until you ask, “Can the user act on this?”&lt;/p&gt;
&lt;p&gt;In this case, the answer was no.&lt;/p&gt;
&lt;p&gt;So the better version became much more boring, and much more honest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;List&amp;lt;ServiceDisplay&amp;gt; resolveServices(Vendor vendor) {
  final services = vendor.services
      .map(mapPricedService)
      .whereType&amp;lt;ServiceDisplay&amp;gt;()
      .take(2)
      .toList();

  if (services.isNotEmpty) return services;

  return const [];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the card could render an empty state instead of pretending there was something to book.&lt;/p&gt;
&lt;p&gt;Something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;if (visibleServices.isEmpty) {
  return Text('This vendor has no services yet.');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not glamorous. Very necessary.&lt;/p&gt;
&lt;h3 id=&quot;the-profile-screen-had-to-tell-the-same-truth&quot;&gt;The profile screen had to tell the same truth&lt;/h3&gt;
&lt;p&gt;The search card was only half the story.&lt;/p&gt;
&lt;p&gt;The vendor profile screen already had a guard in the booking flow. If a user tapped the book button and there were no services, the app showed an error message. Technically correct, but emotionally clumsy.&lt;/p&gt;
&lt;p&gt;From a user’s point of view, the app was saying:&lt;/p&gt;
&lt;p&gt;“Book this vendor.”&lt;/p&gt;
&lt;p&gt;Then after the tap:&lt;/p&gt;
&lt;p&gt;“Actually, no.”&lt;/p&gt;
&lt;p&gt;That is not a great contract.&lt;/p&gt;
&lt;p&gt;So the bottom CTA had to become state-aware. If the vendor had bookable services, keep the normal booking CTA and the small chat button. If the vendor had no services, remove booking entirely and make chat the primary action.&lt;/p&gt;
&lt;p&gt;The important part was not just disabling a button. It was changing the user’s available path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;bool get hasBookableServices =&amp;gt; vendor?.services.isNotEmpty == true;

bool get showChatAsPrimaryCta =&amp;gt;
    !isLoading &amp;amp;&amp;amp; vendor != null &amp;amp;&amp;amp; !hasBookableServices;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That extra loading/vendor check matters. Without it, the screen could briefly think “no services” while the vendor was still loading and show the wrong CTA. Flutter will happily rebuild during every tiny state transition, so the UI has to be careful about what a state actually means.&lt;/p&gt;
&lt;p&gt;There is a difference between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;we have loaded the vendor and they have no services&lt;/li&gt;
&lt;li&gt;we have not loaded the vendor yet&lt;/li&gt;
&lt;li&gt;the request failed and vendor is null&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those states can all look like “empty” if you write the condition too casually.&lt;/p&gt;
&lt;p&gt;That was the small detail that made me pause.&lt;/p&gt;
&lt;h3 id=&quot;search-suggestions-and-the-backend-contract&quot;&gt;Search, suggestions, and the backend contract&lt;/h3&gt;
&lt;p&gt;The next piece was the search behavior itself.&lt;/p&gt;
&lt;p&gt;There was a text field for vendor name search, and there were suggestion chips like “Caterer,” “Event Planner,” “Baker,” and “Rentals.” Originally, tapping a suggestion sent the chip text as the &lt;code&gt;name&lt;/code&gt; query parameter.&lt;/p&gt;
&lt;p&gt;That worked mechanically, but conceptually it was off.&lt;/p&gt;
&lt;p&gt;Those suggestions were not vendor names. They were category or service filters.&lt;/p&gt;
&lt;p&gt;The temptation in a mobile app is to “fix” this locally. Filter the current list in Dart, compare strings, hide cards, call it a day. But that would have been the wrong layer. The backend already owned filtering, pagination, and matching logic. The app’s responsibility was just to call the endpoint with the right parameters and render the result.&lt;/p&gt;
&lt;p&gt;So suggestion taps became category calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static const suggestionCategoryValues = {
  'Caterer': 'CATERER',
  'Sound Engineer': 'SOUND_AND_PA_SYSTEM_PROVIDER',
  'Event Planner': 'EVENT_PLANNER',
  'Rentals': 'RENTALS',
  'Food': 'FOOD_AND_BEVERAGE',
};

Future&amp;lt;void&amp;gt; onSuggestionTapped(String suggestion) async {
  final category = suggestionCategoryValues[suggestion] ?? suggestion;

  searchController.text = suggestion;
  filterCategory = category;

  await fetchVendors(
    name: null,
    category: category,
    page: 1,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That little map is not the most exciting code in the world, but it protects the API contract. Display labels can be friendly. Query values can stay backend-shaped.&lt;/p&gt;
&lt;p&gt;Mobile apps live in that translation layer more often than we admit.&lt;/p&gt;
&lt;h3 id=&quot;the-filter-sheet-caught-up-later&quot;&gt;The filter sheet caught up later&lt;/h3&gt;
&lt;p&gt;Once suggestions were using category filtering, another inconsistency appeared immediately: the bottom sheet had filters for location and price, but not category.&lt;/p&gt;
&lt;p&gt;So category filtering existed only through suggestions. That meant users could discover it by accident, but not intentionally. Not great.&lt;/p&gt;
&lt;p&gt;The filter sheet needed a category dropdown that did the same thing: send a category query to the backend.&lt;/p&gt;
&lt;p&gt;The interesting decision here was what values to show and what values to send. I wanted the UI to say “Food and Beverage,” not &lt;code&gt;FOOD_AND_BEVERAGE&lt;/code&gt;. But the API still needed the enum-style value.&lt;/p&gt;
&lt;p&gt;So the dropdown carried backend values internally and used a display mapper for labels:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static const categoryOptions = [
  'FOOD_AND_BEVERAGE',
  'EVENT_PLANNER',
  'PHOTOGRAPHER',
  'RENTALS',
];

static const categoryLabels = {
  'FOOD_AND_BEVERAGE': 'Food and Beverage',
  'EVENT_PLANNER': 'Event Planner',
  'PHOTOGRAPHER': 'Photographer',
  'RENTALS': 'Rentals',
};

String formatCategory(String value) {
  return categoryLabels[value] ?? value.replaceAll('_', ' ');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is one of those places where clean architecture is not a diagram. It is a tiny choice about which string crosses a boundary.&lt;/p&gt;
&lt;p&gt;The app can be warm and readable. The network request can be strict and boring. Everyone wins.&lt;/p&gt;
&lt;h3 id=&quot;the-sneaky-problem-with-clearing-results&quot;&gt;The sneaky problem with clearing results&lt;/h3&gt;
&lt;p&gt;After the category dropdown went in, another UX issue showed up.&lt;/p&gt;
&lt;p&gt;Text search had a clear button inside the search field. If I typed a vendor name, I could tap the suffix X and reset the search.&lt;/p&gt;
&lt;p&gt;But filters from the bottom sheet did not always put visible text in the search field. So after filtering by category, location, or price, the user could see “Result(s) Found” but had no obvious way to get back to the unfiltered list.&lt;/p&gt;
&lt;p&gt;This is the kind of detail that is easy to miss when testing happy paths. The data was correct. The request was correct. Pagination still worked. But the screen had trapped the user in a filtered state unless they reopened the sheet and manually changed things.&lt;/p&gt;
&lt;p&gt;So I added a small clear action beside the results count.&lt;/p&gt;
&lt;p&gt;Not a giant reset panel. Not another modal. Just an X next to:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;12 Result(s) Found&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;That X clears:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;name search&lt;/li&gt;
&lt;li&gt;category&lt;/li&gt;
&lt;li&gt;location&lt;/li&gt;
&lt;li&gt;price range&lt;/li&gt;
&lt;li&gt;suggestion state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then it reloads page one from the backend with no filters.&lt;/p&gt;
&lt;p&gt;The method is intentionally blunt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; clearSearchAndFilters() async {
  searchController.clear();

  filterLocation = null;
  filterMinPrice = null;
  filterMaxPrice = null;
  filterCategory = null;
  categorySearchText = null;

  await fetchVendors(
    name: null,
    category: null,
    location: null,
    minPrice: null,
    maxPrice: null,
    page: 1,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sometimes the cleanest UX state reset is not clever. It is just complete.&lt;/p&gt;
&lt;h3 id=&quot;the-thing-i-kept-coming-back-to&quot;&gt;The thing I kept coming back to&lt;/h3&gt;
&lt;p&gt;The technical work today was not hard because of algorithms. It was hard because the screen had several overlapping meanings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A search query can be a vendor name.&lt;/li&gt;
&lt;li&gt;A suggestion looks like text but behaves like a category filter.&lt;/li&gt;
&lt;li&gt;A vendor can have declared capabilities but no bookable services.&lt;/li&gt;
&lt;li&gt;A profile can be valid but not bookable.&lt;/li&gt;
&lt;li&gt;Empty data can mean “not loaded yet” or “loaded and truly empty.”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main lesson was that UI should not invent certainty the data does not provide.&lt;/p&gt;
&lt;p&gt;If there are no bookable services, say that.&lt;/p&gt;
&lt;p&gt;If the backend owns filtering, call the backend.&lt;/p&gt;
&lt;p&gt;If a filter can be applied, give the user a way to clear it.&lt;/p&gt;
&lt;p&gt;Tiny decisions. Big difference.&lt;/p&gt;
&lt;p&gt;By the end, the screen felt less magical and more honest. That is usually a good trade.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16338/the-day-search-wasnt-just-search</guid>
<pubDate>Fri, 01 May 2026 15:26:17 +0000</pubDate>
</item>
<item>
<title>Building a Zomato-Like Multi-City Food Delivery App with Flutter &amp; Firebase</title>
<link>https://coderlegion.com/16044/building-a-zomato-like-multi-city-food-delivery-app-with-flutter-firebase</link>
<description>&lt;p&gt;Hey everyone, Surendra Kumar here —Flutter/Firebase developer from Banda, India. I've been building production apps under Gfood Delivery Private Limited, and today I want to share the architecture behind my most ambitious project yet: Zesto, a multi-city food delivery platform built entirely with Flutter and Firebase.&lt;br&gt;
Zesto isn't a tutorial project. It's a real, working platform — 4 Flutter apps, a complete Firebase backend, and architecture decisions modeled after how production platforms like Zomato actually work.&lt;br&gt;
Want to try it before reading? Download the customer app APK and test it yourself:&lt;br&gt;
 Download Zesto Customer App &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk&quot;&gt;https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk&lt;/a&gt;&lt;br&gt;
I'd love your feedback and improvement ideas after reading.&lt;/p&gt;
&lt;p&gt;The 4-App Ecosystem&lt;br&gt;
Zesto consists of four interconnected Flutter applications:&lt;/p&gt;
&lt;p&gt;Customer App — Multi-city restaurant discovery, cart, Razorpay + wallet payments, live GPS tracking, reviews&lt;br&gt;
Restaurant Partner App — Order management, menu CRUD, earnings dashboard, FCM order alerts&lt;br&gt;
Driver App — Order accept/reject, live delivery tracking, earnings, payout requests&lt;br&gt;
Flutter Web Admin Panel — 14 screens covering cities, zones, restaurants, drivers, orders, analytics, reviews, ads, config, and audit logs&lt;/p&gt;
&lt;p&gt;The Core Architecture Decision: City + Zone Scoping&lt;br&gt;
This is the most important architectural decision in Zesto — and the one that separates it from basic food delivery tutorials.&lt;br&gt;
Every single Firestore document has two fields:&lt;br&gt;
dart{&lt;br&gt;
  &quot;city_id&quot;: &quot;banda_up&quot;,&lt;br&gt;
  &quot;zone_id&quot;: &quot;banda_zone_1&quot;,&lt;br&gt;
  // ... rest of document&lt;br&gt;
}&lt;br&gt;
Every query filters by city_id first:&lt;br&gt;
dartFirebaseFirestore.instance&lt;br&gt;
  .collection('restaurants')&lt;br&gt;
  .where('city_id', isEqualTo: currentCityId)&lt;br&gt;
  .where('is_active', isEqualTo: true)&lt;br&gt;
  .snapshots();&lt;br&gt;
Why does this matter? Without city scoping, as you add cities your queries return restaurants from everywhere. Firestore has no built-in geographic filtering — you have to design it in from day one. Retrofitting this later would require rewriting every query and migrating every document.&lt;br&gt;
Zones sit inside cities and control driver assignment. When an order is placed, the Cloud Function assigns it to available drivers in the same zone first, then expands to the full city if no zone drivers are available.&lt;/p&gt;
&lt;p&gt;Firebase Custom Claims for RBAC&lt;br&gt;
Zesto has 5 user roles: customer, restaurant_owner, driver, admin, super_admin. Managing this with Firestore documents alone creates security holes — a user could theoretically write to their own document and escalate their role.&lt;br&gt;
The solution is Firebase custom claims set via Cloud Functions:&lt;br&gt;
javascript// functions/src/admin/setUserRole.js&lt;br&gt;
exports.setUserRole = functions.https.onCall(async (data, context) =&amp;gt; {&lt;br&gt;
  if (!context.auth.token.super_admin) {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;throw new functions.https.HttpsError('permission-denied', 'Not authorized');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;br&gt;
  await admin.auth().setCustomUserClaims(data.uid, {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;role: data.role,
city_id: data.cityId,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  });&lt;br&gt;
});&lt;br&gt;
On the Flutter side, the role is read from the ID token directly:&lt;br&gt;
dartfinal token = await FirebaseAuth.instance.currentUser?.getIdTokenResult();&lt;br&gt;
final role = token?.claims?['role'] as String?;&lt;br&gt;
This means even if someone modifies their Firestore document, the custom claim on the token is what gates access. Firestore rules enforce this:&lt;br&gt;
javascript// firestore.rules&lt;br&gt;
match /orders/{orderId} {&lt;br&gt;
  allow read: if request.auth.token.role == 'admin'&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;|| request.auth.token.role == 'restaurant_owner'
|| request.auth.uid == resource.data.customer_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;}&lt;/p&gt;
&lt;p&gt;MobX for State Management&lt;br&gt;
Zesto uses MobX across all 4 apps. Here's why I chose it over Riverpod or GetX for this project:&lt;br&gt;
MobX's reactive observables map perfectly to Firestore streams. When a Firestore stream emits a new value, MobX automatically rebuilds only the widgets that consume that observable — no manual setState, no context.watch boilerplate.&lt;br&gt;
dart// core/stores/order_store.dart&lt;br&gt;
class OrderStore = &lt;em&gt;OrderStore with &lt;/em&gt;$OrderStore;&lt;/p&gt;
&lt;p&gt;abstract class _OrderStore with Store {&lt;br&gt;
  @observable&lt;br&gt;
  ObservableList activeOrders = ObservableList();&lt;/p&gt;
&lt;p&gt;  @observable&lt;br&gt;
  OrderStatus currentStatus = OrderStatus.placed;&lt;/p&gt;
&lt;p&gt;  @action&lt;br&gt;
  void updateOrderStatus(String orderId, OrderStatus status) {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;final idx = activeOrders.indexWhere((o) =&amp;gt; o.id == orderId);
if (idx != -1) {
  activeOrders[idx] = activeOrders[idx].copyWith(status: status);
}
currentStatus = status;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;br&gt;
}&lt;br&gt;
The store subscribes to a Firestore stream once and updates observables. Every widget that reads activeOrders rebuilds automatically. Clean, testable, no rebuild storms.&lt;/p&gt;
&lt;p&gt;Dual Payment Path: Razorpay + In-App Wallet&lt;br&gt;
Zesto supports two payment methods and they interact with each other in interesting ways.&lt;br&gt;
Razorpay handles card/UPI payments. The flow is standard — create an order via Cloud Functions, open Razorpay checkout, handle the webhook callback.&lt;br&gt;
The wallet is more interesting. Each customer has a wallet_balance field on their user document. When they pay via wallet:&lt;br&gt;
javascript// functions/src/payments/walletPayment.js&lt;br&gt;
exports.processWalletPayment = functions.https.onCall(async (data, context) =&amp;gt; {&lt;br&gt;
  const uid = context.auth.uid;&lt;/p&gt;
&lt;p&gt;  return admin.firestore().runTransaction(async (transaction) =&amp;gt; {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const userRef = admin.firestore().collection('users').doc(uid);
const userDoc = await transaction.get(userRef);

const currentBalance = userDoc.data().wallet_balance;
if (currentBalance &amp;lt; data.amount) {
  throw new functions.https.HttpsError('failed-precondition', 'Insufficient balance');
}

transaction.update(userRef, {
  wallet_balance: admin.firestore.FieldValue.increment(-data.amount)
});

transaction.set(admin.firestore().collection('wallet_txns').doc(), {
  uid,
  amount: -data.amount,
  type: 'debit',
  order_id: data.orderId,
  created_at: admin.firestore.FieldValue.serverTimestamp(),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  });&lt;br&gt;
});&lt;br&gt;
The Firestore transaction ensures the balance check and deduction happen atomically — no race condition where two simultaneous orders drain the wallet below zero.&lt;/p&gt;
&lt;p&gt;Cloud Functions: Domain-Split Architecture&lt;br&gt;
Instead of one massive index.js, Zesto's Cloud Functions are split by domain:&lt;br&gt;
functions/src/&lt;br&gt;
├── orders/&lt;br&gt;
│   ├── placeOrder.js       # Create order, notify restaurant&lt;br&gt;
│   ├── assignDriver.js     # Zone-based driver assignment&lt;br&gt;
│   └── onOrderDelivered.js # Earnings calculation, wallet credit&lt;br&gt;
├── payments/&lt;br&gt;
│   ├── razorpayWebhook.js  # Payment confirmation&lt;br&gt;
│   └── walletPayment.js    # Wallet debit/credit&lt;br&gt;
└── admin/&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;├── approveRestaurant.js
└── setUserRole.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each function is independently deployable. When I fix a bug in assignDriver.js, I deploy only that function — not the entire backend.&lt;/p&gt;
&lt;p&gt;The Driver Assignment Problem&lt;br&gt;
When an order is placed, the platform needs to assign a driver. Here's the simplified logic:&lt;br&gt;
javascriptexports.assignDriver = functions.firestore&lt;br&gt;
  .document('orders/{orderId}')&lt;br&gt;
  .onCreate(async (snap, context) =&amp;gt; {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const order = snap.data();

// 1. Find online drivers in the same zone
const driversSnap = await admin.firestore()
  .collection('drivers')
  .where('city_id', isEqualTo: order.city_id)
  .where('zone_id', isEqualTo: order.zone_id)
  .where('is_online', isEqualTo: true)
  .where('current_order_id', isEqualTo: null)
  .limit(5)
  .get();

if (driversSnap.empty) {
  // Expand to full city
  // ... same query without zone_id filter
}

// 2. Send FCM notification to available drivers
// First driver to accept wins (handled by transaction in Driver app)
const tokens = driversSnap.docs.map(d =&amp;gt; d.data().fcm_token);
await admin.messaging().sendMulticast({
  tokens,
  data: { type: 'NEW_ORDER', order_id: context.params.orderId }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  });&lt;br&gt;
On the driver side, accepting an order uses a Firestore transaction with three guards to prevent race conditions — only one driver can claim the order even if multiple tap Accept simultaneously.&lt;/p&gt;
&lt;p&gt;What's Coming&lt;br&gt;
Zesto is launching soon on Gumroad and Codester as commercial source code — complete, ready-to-customize. It includes all 4 Flutter apps, the complete Cloud Functions backend, Firestore security rules, and developer documentation.&lt;br&gt;
Try the customer app right now:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk&quot;&gt;https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk&lt;/a&gt;&lt;br&gt;
I'd genuinely love your feedback — what works well, what feels off, what you'd build differently. Drop a comment or reach me at &lt;em&gt;Emails are not allowed&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Surendra Kumar — Flutter/Firebase developer, Banda, India&lt;br&gt;
Portfolio: portfolio.gfood.in | GitHub: ssurekumar01111-hue&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16044/building-a-zomato-like-multi-city-food-delivery-app-with-flutter-firebase</guid>
<pubDate>Tue, 28 Apr 2026 05:29:56 +0000</pubDate>
</item>
<item>
<title>How I Built a Complete On-Demand Home Services App (ServeNow) with Flutter &amp; Firebase</title>
<link>https://coderlegion.com/16036/how-i-built-a-complete-on-demand-home-services-app-servenow-with-flutter-firebase</link>
<description>&lt;p&gt;Hey everyone, Surendra Kumar here, a self-taught Flutter developer from Banda, India. I've spent a good chunk of my time building and shipping production apps, like those I worked on for Gfood Delivery Private Limited. Today, I want to share the story behind one of my most ambitious projects: ServeNow.&lt;/p&gt;
&lt;p&gt;As an indie developer, I've always been driven to build &lt;em&gt;real&lt;/em&gt; solutions—apps that don't just look pretty in a tutorial but actually work robustly in the wild. That's exactly the philosophy I brought to ServeNow.&lt;/p&gt;
&lt;h4 id=&quot;what-is-servenow-crafting-a-full-fledged-marketplace&quot;&gt;What is ServeNow? Crafting a Full-Fledged Marketplace&lt;/h4&gt;
&lt;p&gt;ServeNow isn't just another demo app. It's a complete, production-ready on-demand home services marketplace built from the ground up with Flutter and Firebase. My goal was to create a comprehensive system that could handle everything from booking a beautician to fixing a leaky faucet, all while providing a smooth experience for customers, service providers, and administrators.&lt;/p&gt;
&lt;p&gt;To achieve this, I engineered ServeNow as a suite of four interconnected applications, each designed for a specific user role:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Customer App&lt;/strong&gt;: This is where users come to life. They can browse various services (beauty, cleaning, plumbing, electrical, etc.), book appointments, track their service provider in real-time, chat with them, make secure payments via Razorpay, and leave reviews.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provider App&lt;/strong&gt;: For the businesses offering services. They can accept new bookings, manage their schedule and service offerings, track their real-time earnings, and request payouts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handyman App&lt;/strong&gt;: This is for the actual service professionals. It's a multi-service pro app featuring a broadcast job matching system, allowing handymen to pick up nearby jobs. It also includes an onboarding and approval flow for new pros.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flutter Web Admin Panel&lt;/strong&gt;: The control center. This robust web panel gives administrators full control over users, bookings, categories, service providers, payout requests, and provides crucial analytics to keep the marketplace running efficiently.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;why-flutter-amp-firebase-my-tech-stack-decisions&quot;&gt;Why Flutter &amp;amp; Firebase? My Tech Stack Decisions&lt;/h4&gt;
&lt;p&gt;When embarking on ServeNow, the choice of technology was critical. I needed a stack that offered rapid development, scalability, real-time capabilities, and robust performance without requiring a massive infrastructure investment.&lt;/p&gt;
&lt;h5 id=&quot;flutter-dart-for-the-frontend&quot;&gt;Flutter (Dart) for the Frontend&lt;/h5&gt;
&lt;p&gt;Flutter was a no-brainer for the mobile applications and the web admin panel.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single Codebase&lt;/strong&gt;: Building four distinct applications (Customer, Provider, Handyman, and Admin Panel) from a single Flutter codebase drastically cut down development time and ensured UI/UX consistency across platforms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance&lt;/strong&gt;: Flutter's native compilation and widget-based architecture deliver smooth, high-performance applications that feel truly native.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer Experience&lt;/strong&gt;: Hot Reload and a rich widget catalog accelerated my development cycles, allowing for quick iterations and bug fixes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;firebase-for-the-backend-my-go-to-for-indie-development-&quot;&gt;Firebase for the Backend (My Go-To for Indie Development)&lt;/h5&gt;
&lt;p&gt;Firebase has been my backend of choice for many projects, and ServeNow was no exception. It offered the complete suite of tools I needed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Firestore&lt;/strong&gt;: This is the heart of ServeNow's data. I chose Firestore for its real-time capabilities, strong querying, and scalability.&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Architecture Decision&lt;/strong&gt;: I structured collections like &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;services&lt;/code&gt;, &lt;code&gt;providers&lt;/code&gt;, &lt;code&gt;handymen&lt;/code&gt;, &lt;code&gt;bookings&lt;/code&gt;, and &lt;code&gt;chats&lt;/code&gt;. For bookings, I leaned into &lt;strong&gt;denormalization&lt;/strong&gt; by embedding critical but static details (like customer name, service price) directly within the &lt;code&gt;bookings&lt;/code&gt; document. This minimizes expensive cross-collection reads for common operations and ensures faster data retrieval, especially for lists of bookings. Subcollections were used for highly related, nested data, like &lt;code&gt;messages&lt;/code&gt; within a &lt;code&gt;chat&lt;/code&gt; document.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase Authentication&lt;/strong&gt;: For secure and easy user management across all apps, supporting email/password and potentially Google Sign-In for future expansion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud Functions (JavaScript)&lt;/strong&gt;: This was indispensable for server-side logic without managing actual servers.&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Architecture Decision&lt;/strong&gt;: Cloud Functions handle critical backend tasks:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Razorpay Webhooks&lt;/strong&gt;: Securely processing payment callbacks and updating booking statuses post-payment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FCM Triggers&lt;/strong&gt;: Sending real-time push notifications for booking updates, new job broadcasts (to handymen), and chat messages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Aggregation&lt;/strong&gt;: Calculating real-time earnings for providers and handymen, and managing payout requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background Tasks&lt;/strong&gt;: Handling approval flows for new handymen and providers.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase Storage&lt;/strong&gt;: For storing user profile pictures, service images, and any other media files securely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase Cloud Messaging (FCM)&lt;/strong&gt;: Essential for real-time communication between the apps – from notifying handymen about new jobs in their area to letting customers know their service provider is on the way.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;other-key-technologies-&quot;&gt;Other Key Technologies:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Razorpay&lt;/strong&gt;: Integrated as the primary payment gateway, crucial for handling transactions securely within the Indian market. Cloud Functions were vital here for handling payment success/failure callbacks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rxdart&lt;/strong&gt;: I leveraged rxdart for reactive programming, especially for managing complex streams of data and state within the apps, ensuring a smooth and responsive user experience.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google Maps&lt;/strong&gt;: Integrated for live tracking of handymen, defining service areas, and visualizing job locations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;architectural-decisions-amp-overcoming-challenges&quot;&gt;Architectural Decisions &amp;amp; Overcoming Challenges&lt;/h4&gt;
&lt;p&gt;Building ServeNow meant tackling typical marketplace complexities.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Real-time Synchronization&lt;/strong&gt;: A core requirement. Firestore's real-time listeners were used extensively. For example, when a handyman accepts a job, the customer's app updates instantly, and the provider's app reflects the booking status change. This required careful management of data updates and notification triggers via Cloud Functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scalability&lt;/strong&gt;: Thinking beyond just a few users. Firestore's inherent scalability and Cloud Functions' serverless architecture meant the platform could grow without significant re-engineering of the backend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Job Broadcasting &amp;amp; Matching&lt;/strong&gt;: This was an interesting challenge for the Handyman app. When a new service request comes in, I use Firestore queries based on service type and location, combined with FCM, to broadcast the job to eligible handymen. The first to accept gets the job, with race conditions handled by Firestore transactions in Cloud Functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secure Payouts&lt;/strong&gt;: The provider and handyman apps needed a robust payout request system. This involves tracking earnings in Firestore, initiating payout requests, and then an admin manually (or via a future integration) processing these, all managed through the Admin Panel and Cloud Functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer Documentation&lt;/strong&gt;: Knowing that this was a complex system, I invested significant time in creating a comprehensive 16-page developer documentation (v2.0). It covers everything from project setup to API details, making it easy for anyone to get started.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-indie-developer-ethos-production-grade-from-day-one&quot;&gt;The Indie Developer Ethos: Production-Grade from Day One&lt;/h4&gt;
&lt;p&gt;What makes ServeNow different is that it's built with a &quot;production-grade&quot; mindset. This isn't a collection of loosely connected tutorials; it's a fully integrated, tested system designed to be deployed and used in a real business environment. Every feature, every line of code, was written with stability, scalability, and maintainability in mind. It comes complete with signed APKs and a thorough setup guide, so you can literally launch it.&lt;/p&gt;
&lt;p&gt;Building ServeNow was a journey of applying everything I've learned about Flutter, Firebase, and shipping real products. It's proof that with dedication and the right tools, indie developers can build incredibly powerful, complete solutions.&lt;/p&gt;
&lt;h4 id=&quot;ready-to-launch-your-own-home-services-marketplace-&quot;&gt;Ready to Launch Your Own Home Services Marketplace?&lt;/h4&gt;
&lt;p&gt;If you're looking for a robust, ready-to-launch foundation for an on-demand home services marketplace, ServeNow is designed to be exactly that. It handles the complexities, so you can focus on your business.&lt;/p&gt;
&lt;p&gt;You can learn more about my work and projects like ServeNow by checking out my GitHub profile or reaching out via email.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;My GitHub Profile&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue&quot;&gt;ssurekumar01111-hue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Email&lt;/strong&gt;: ssure.kumar01111@gmail.com&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://morningstar47jb.gumroad.com/l/viqos&quot;&gt;https://morningstar47jb.gumroad.com/l/viqos&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thanks for reading! I hope this deep dive into ServeNow gives you some insights into building complex applications as an indie developer.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16036/how-i-built-a-complete-on-demand-home-services-app-servenow-with-flutter-firebase</guid>
<pubDate>Tue, 28 Apr 2026 04:39:08 +0000</pubDate>
</item>
<item>
<title>Building a Resilient Real-Time WebSocket Stream in Flutter (RxDart + Clean Architecture + BLoC)</title>
<link>https://coderlegion.com/15939/building-resilient-real-time-websocket-stream-in-flutter-rxdart-clean-architecture-bloc</link>
<description>&lt;p&gt;Most tutorials show how to connect to a WebSocket.&lt;/p&gt;
&lt;p&gt;Very few show what happens when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The network drops&lt;/li&gt;
&lt;li&gt;DNS fails&lt;/li&gt;
&lt;li&gt;The server stops sending data&lt;/li&gt;
&lt;li&gt;The stream silently hangs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This article focuses on handling those cases properly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;
&lt;p&gt;A basic WebSocket implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final channel = WebSocketChannel.connect(uri);
channel.stream.listen(...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Works… until real-world conditions appear:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Network loss → no error  
Stream freezes silently  
App never reconnects  
Users see stale data  
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-goal&quot;&gt;The Goal&lt;/h2&gt;
&lt;p&gt;We want a stream that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;reconnects automatically&lt;/li&gt;
&lt;li&gt;handles both errors and silent failures&lt;/li&gt;
&lt;li&gt;retries with exponential backoff&lt;/li&gt;
&lt;li&gt;does not crash the app&lt;/li&gt;
&lt;li&gt;integrates cleanly with BLoC&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-1-deferred-connection-rx-defer-&quot;&gt;Step 1: Deferred Connection (Rx.defer)&lt;/h2&gt;
&lt;p&gt;Instead of creating the connection immediately, we defer it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;return Rx.defer(() async* {
  final channel = _websocketService.connect();
  await channel. ready;
  yield* channel.stream;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ensures that each retry creates a fresh connection.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-2-handle-connection-errors&quot;&gt;Step 2: Handle Connection Errors&lt;/h2&gt;
&lt;p&gt;If the connection fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;try {
  final channel = _websocketService.connect();
} catch (e) {
  yield* Stream.error(e);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Convert failures into stream errors so retry logic can handle them.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-3-handle-silent-failures-timeout-&quot;&gt;Step 3: Handle Silent Failures (Timeout)&lt;/h2&gt;
&lt;p&gt;WebSockets may stop emitting without throwing errors.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;.timeout(
  const Duration(seconds: 5),
  onTimeout: (sink) {
    sink.addError(&quot;Timeout&quot;);
  },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces a retry when no data is received.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-4-control-update-frequency&quot;&gt;Step 4: Control Update Frequency&lt;/h2&gt;
&lt;p&gt;Real-time streams can overwhelm the UI.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;.sampleTime(const Duration(seconds: 1))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This emits the latest value at a fixed interval.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-5-retry-logic-retrywhenstream-&quot;&gt;Step 5: Retry Logic (RetryWhenStream)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;RetryWhenStream(
  streamFactory,
  (error, _) {
    final delay = Duration(seconds: (1 &amp;lt;&amp;lt; attempt).clamp(1, 60));
    attempt++;
    return Rx.timer(null, delay);
  },
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This implements exponential backoff:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Retry after 1s → 2s → 4s → 8s → ...
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-remote-data-source&quot;&gt;Final Remote Data Source&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Stream&amp;lt;AllMidsModel&amp;gt; connectToStream() {
  var attempt = 0;

  return RetryWhenStream(
    () {
      return Rx.defer(() async* {
        try {
          final channel = _websocketService.connect();
          await channel.ready;

          yield* channel.stream
              .map((event) {
                final json = jsonDecode(event as String);

                if (json['channel'] == 'allMids') {
                  return AllMidsModel.fromJson(json['data']);
                }
                return null;
              })
              .whereType&amp;lt;AllMidsModel&amp;gt;()
              .timeout(
                const Duration(seconds: 5),
                onTimeout: (sink) =&amp;gt; sink.addError(&quot;Timeout&quot;),
              )
              .sampleTime(const Duration(seconds: 1))
              .doOnData((_) =&amp;gt; attempt = 0);
        } catch (e) {
          yield* Stream.error(e);
        }
      });
    },
    (error, _) {
      final delay = Duration(seconds: (1 &amp;lt;&amp;lt; attempt).clamp(1, 60));
      attempt++;
      return Rx.timer(null, delay);
    },
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;clean-architecture-integration&quot;&gt;Clean Architecture Integration&lt;/h2&gt;
&lt;h3 id=&quot;data-layer&quot;&gt;Data Layer&lt;/h3&gt;
&lt;p&gt;Handles WebSocket connection, retry logic, and parsing. Returns a stream of models.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;domain-layer&quot;&gt;Domain Layer&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;abstract interface class PriceRepository {
  Either&amp;lt;Failure, Stream&amp;lt;AllMids&amp;gt;&amp;gt; subscribe();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Responsible for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;mapping models to entities&lt;/li&gt;
&lt;li&gt;converting exceptions into failures&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;use-case&quot;&gt;Use Case&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class SubscribeToStream implements StreamUseCase&amp;lt;AllMids, NoParams&amp;gt; {
  final PriceRepository repo;

  @override
  call(_) =&amp;gt; repo.subscribe();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;presentation-layer-bloc-&quot;&gt;Presentation Layer (BLoC)&lt;/h3&gt;
&lt;p&gt;Instead of passing streams to the UI, BLoC consumes the stream:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;on&amp;lt;SubscribeToPriceStream&amp;gt;(
  _onSubscribe,
  transformer: restartable(),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;await emit.forEach(
  stream,
  onData: (data) =&amp;gt; PriceUpdated(data),
  onError: (e, _) =&amp;gt; PriceStreamError(e.toString()),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-restartable-&quot;&gt;Why &lt;code&gt;restartable()&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;It ensures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;new events cancel previous subscriptions&lt;/li&gt;
&lt;li&gt;No duplicate streams remain active&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ui-layer&quot;&gt;UI Layer&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;switch (state) {
  case PriceUpdated(:final data):
    return Text(&quot;${data.price}&quot;);

  case PriceStreamError(:final msg):
    return Text(msg);

  case StartingPriceStream():
    return CircularProgressIndicator();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The UI reacts to states only. It has no knowledge of streams.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-this-solves&quot;&gt;What This Solves&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;automatic reconnection&lt;/li&gt;
&lt;li&gt;silent failure handling&lt;/li&gt;
&lt;li&gt;controlled UI updates&lt;/li&gt;
&lt;li&gt;crash prevention&lt;/li&gt;
&lt;li&gt;clean separation of concerns&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;key-takeaways&quot;&gt;Key Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;WebSockets do not always fail with errors&lt;/li&gt;
&lt;li&gt;You must handle both errors and inactivity&lt;/li&gt;
&lt;li&gt;Retry logic must recreate the connection&lt;/li&gt;
&lt;li&gt;Streams should be owned by BLoC, not UI&lt;/li&gt;
&lt;li&gt;RxDart is powerful when used with intent&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thought&quot;&gt;Final Thought&lt;/h2&gt;
&lt;p&gt;Real-time systems are not about receiving data.&lt;/p&gt;
&lt;p&gt;They are about handling failure conditions reliably, so users never notice them.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/15939/building-resilient-real-time-websocket-stream-in-flutter-rxdart-clean-architecture-bloc</guid>
<pubDate>Mon, 27 Apr 2026 05:30:51 +0000</pubDate>
</item>
<item>
<title>The Tiny Red Dot That Wasn't Tiny
</title>
<link>https://coderlegion.com/15394/the-tiny-red-dot-that-wasnt-tiny</link>
<description>&lt;p&gt;Today’s task looked harmless from the outside: show unread notifications, let users mark them as seen, and put a red dot on the bell icon when there is something new.&lt;/p&gt;
&lt;p&gt;Classic mobile app stuff. Nothing dramatic. Just a bell, a list, a few cards, and one dot.&lt;/p&gt;
&lt;p&gt;Naturally, it was not that simple.&lt;/p&gt;
&lt;p&gt;The funny thing about notification work is that it lives in the uncomfortable space between backend truth and frontend immediacy. The backend knows what is actually unread. The UI knows what the user just tapped. Push notifications know something happened. Sockets know something happened too, sometimes faster. And snackbars are off on the side saying, “Hey, should I also be involved?”&lt;/p&gt;
&lt;p&gt;By midday, the red dot was no longer a red dot. It was a state synchronization problem wearing a very small costume.&lt;/p&gt;
&lt;h3 id=&quot;the-backend-changed-its-mind-which-was-actually-helpful&quot;&gt;The backend changed its mind, which was actually helpful&lt;/h3&gt;
&lt;p&gt;The first shape of the notification payload was fairly raw. Every notification had a type, a timestamp, a read flag, and optional metadata. Some metadata had a &lt;code&gt;bookingId&lt;/code&gt;, messages had sender/message IDs, and a few account-related notifications had no metadata at all.&lt;/p&gt;
&lt;p&gt;Then the backend evolved in a useful direction: every notification metadata payload started carrying a &lt;code&gt;summary&lt;/code&gt; field.&lt;/p&gt;
&lt;p&gt;That changed the UI strategy quite a bit.&lt;/p&gt;
&lt;p&gt;Instead of the app inventing every notification message from scratch, the backend could provide the user-facing summary while Flutter handled presentation: title, tone, icon/avatar, time label, action hints, and where the tap should eventually go.&lt;/p&gt;
&lt;p&gt;That felt like the right division of responsibility.&lt;/p&gt;
&lt;p&gt;The server knows the event context. The app knows how to make it feel native.&lt;/p&gt;
&lt;p&gt;So the mapper became less of a sentence factory and more of a translation layer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class NotificationUiItem {
  final String id;
  final String type;
  final String title;
  final String summary;
  final DateTime createdAt;
  final bool read;
  final String? bookingId;
  final String? serviceId;
  final String? senderId;
  final String? messageId;
  final String? photo;
  final NotificationUiTone tone;

  const NotificationUiItem({
    required this.id,
    required this.type,
    required this.title,
    required this.summary,
    required this.createdAt,
    required this.read,
    required this.tone,
    this.bookingId,
    this.serviceId,
    this.senderId,
    this.messageId,
    this.photo,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing wild there, but it gave the UI a stable language. Client notifications and vendor notifications could both consume the same backend response without pretending they were separate worlds.&lt;/p&gt;
&lt;p&gt;That was one of the nicer parts of the day: deleting mental duplication before it had time to harden.&lt;/p&gt;
&lt;h3 id=&quot;the-trap-just-fetch-when-something-happens-&quot;&gt;The trap: “just fetch when something happens”&lt;/h3&gt;
&lt;p&gt;At some point I had to decide how the red dot should react to realtime events.&lt;/p&gt;
&lt;p&gt;The obvious approach is tempting:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Socket event comes in, call &lt;code&gt;GET https://api.example.com/notifications&lt;/code&gt;, update the unread state.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It sounds clean because the backend remains the single source of truth. But it also means every realtime event can become a network call. Booking accepted? Fetch. Client paid? Fetch. Vendor account approved? Fetch. Push received while the app is open? Fetch again.&lt;/p&gt;
&lt;p&gt;That starts to feel a little too eager.&lt;/p&gt;
&lt;p&gt;The red dot does not need the full notification list. It only needs to know whether there is probably something unread.&lt;/p&gt;
&lt;p&gt;So I split the behavior into two layers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Realtime events are allowed to set &lt;code&gt;hasUnread&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The actual notifications endpoint is fetched when the user opens or refreshes the notifications screen.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tapping or clearing notifications updates the badge optimistically.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A successful refresh becomes the source of truth again.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The controller ended up intentionally small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class UnreadNotificationsController {
  final ValueNotifier&amp;lt;bool&amp;gt; hasUnread = ValueNotifier&amp;lt;bool&amp;gt;(false);

  Future&amp;lt;void&amp;gt; refreshFromBackend() async {
    final result = await getUnreadNotifications();

    result.fold(
      (_) {
        // Keep the current local state if refresh fails.
      },
      (response) {
        hasUnread.value = response.notifications.isNotEmpty;
      },
    );
  }

  void notifyNewUnread() {
    hasUnread.value = true;
  }

  void markAllSeenLocally() {
    hasUnread.value = false;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The small detail I care about there: failed refresh does not force the dot off.&lt;/p&gt;
&lt;p&gt;That is the kind of bug that feels tiny until a user sees it. Imagine a socket event turns the dot on, then a shaky network request fails, and suddenly the app says there are no notifications. That is not truth. That is the UI being overconfident.&lt;/p&gt;
&lt;p&gt;So failure preserves the last known state.&lt;/p&gt;
&lt;h3 id=&quot;optimistic-ui-but-not-reckless-ui&quot;&gt;Optimistic UI, but not reckless UI&lt;/h3&gt;
&lt;p&gt;The notification cards needed two actions: tap one notification as seen, and clear all notifications.&lt;/p&gt;
&lt;p&gt;Both should feel immediate. Nobody wants to tap a notification and wait for a spinner before the card visually calms down.&lt;/p&gt;
&lt;p&gt;So the screen fades a card instantly when tapped, calls the mark-as-seen endpoint in the background, and restores state only if the request fails.&lt;/p&gt;
&lt;p&gt;Same with clear-all: empty the list immediately, turn off the badge, then restore if the backend refuses.&lt;/p&gt;
&lt;p&gt;That pattern is simple on paper, but it gets tricky around shared state. The screen has local state like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final Set&amp;lt;String&amp;gt; seenNotificationIds = {};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The badge has global-ish app state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final ValueNotifier&amp;lt;bool&amp;gt; hasUnread;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those two need to agree without becoming the same thing.&lt;/p&gt;
&lt;p&gt;Local card fading is about presentation. The badge is about app-level unread status. If one notification is tapped but others remain unread, the card should fade but the bell should stay red. If the last visible unread notification is tapped, the bell can turn off optimistically.&lt;/p&gt;
&lt;p&gt;That distinction saved the implementation from becoming mushy.&lt;/p&gt;
&lt;h3 id=&quot;messages-were-the-one-awkward-guest&quot;&gt;Messages were the one awkward guest&lt;/h3&gt;
&lt;p&gt;Messages complicated the decision a bit.&lt;/p&gt;
&lt;p&gt;The app already has message socket events and snackbars. A new chat message can appear while the user is in another tab, and the app may show a snackbar preview. The backend may also create a notification for that message. But once the user reads the chat, the backend can mark that message notification as read on the next fetch.&lt;/p&gt;
&lt;p&gt;So should message events turn on the notification bell dot?&lt;/p&gt;
&lt;p&gt;I chose not to, at least for now.&lt;/p&gt;
&lt;p&gt;Not because messages are unimportant. Actually, the opposite: messages often deserve their own unread behavior. If the chat tab already has message-specific state, using the general notification bell for every message can make the app feel noisy.&lt;/p&gt;
&lt;p&gt;So the controller ignores message-style push events and lets the backend refresh settle it later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void handlePushPayload(PushPayload payload) {
  final eventType = payload.eventType.trim();

  if (payload.isMessageEvent || eventType == 'NEW_MESSAGE') return;
  if (eventType.isEmpty) return;

  notifyNewUnread();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means booking, payment, account, document, and status notifications can light the bell immediately. Messages can still appear in the notifications list if the backend returns them, but they do not drive the red dot in realtime.&lt;/p&gt;
&lt;p&gt;That might change later. And if it does, the change is thankfully tiny: remove that guard and subscribe to message socket events too.&lt;/p&gt;
&lt;p&gt;I like when a decision is reversible without surgery.&lt;/p&gt;
&lt;h3 id=&quot;the-part-that-felt-architectural&quot;&gt;The part that felt architectural&lt;/h3&gt;
&lt;p&gt;The more interesting thought came after the unread state was working: what should happen when a user taps a notification card?&lt;/p&gt;
&lt;p&gt;Push notifications already navigate somewhere. Usually not super deep, but enough to open the right section of the app. For example, a booking event might open the bookings tab. A message might open the messages tab. A payout-related notification might open the payout screen.&lt;/p&gt;
&lt;p&gt;The in-app notification cards should not invent a second routing brain.&lt;/p&gt;
&lt;p&gt;The cleaner direction is to create a shared notification navigation resolver. Push payloads and backend notification cards can both be converted into the same internal input:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class NotificationRouteInput {
  final String type;
  final String? bookingId;
  final String? serviceId;
  final String? senderId;
  final String? messageId;
  final String? vendorId;

  const NotificationRouteInput({
    required this.type,
    this.bookingId,
    this.serviceId,
    this.senderId,
    this.messageId,
    this.vendorId,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the resolver decides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;message notification: messages tab, or exact chat if enough data exists&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;booking notification: bookings tab, ideally with the exact booking opened&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;document notification: payout or document area&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;account rejected/suspended: support or account status screen&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;vendor application: admin approval flow, if the app has one&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The important thing is consistency. If a push notification and an in-app notification represent the same event, they should not send the user to different places because two separate pieces of code drifted apart.&lt;/p&gt;
&lt;p&gt;That is the kind of duplication that does not hurt today, then quietly taxes every future feature.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-keep-from-today&quot;&gt;What I’d keep from today&lt;/h3&gt;
&lt;p&gt;The biggest lesson was not about &lt;code&gt;ValueNotifier&lt;/code&gt;, sockets, or endpoints. It was about being careful with what kind of truth each part of the app owns.&lt;/p&gt;
&lt;p&gt;The backend owns durable unread state.&lt;/p&gt;
&lt;p&gt;Sockets and push own immediacy.&lt;/p&gt;
&lt;p&gt;Notification screens own presentation and local optimistic feedback.&lt;/p&gt;
&lt;p&gt;The badge owns one tiny app-level question: “Should I show a dot?”&lt;/p&gt;
&lt;p&gt;Once those boundaries were clear, the implementation stopped feeling like a pile of callbacks and started feeling like a small system.&lt;/p&gt;
&lt;p&gt;Still a slightly annoying system. Notifications always are. They are where product expectations, backend timing, OS behavior, and user attention all meet in a narrow hallway.&lt;/p&gt;
&lt;p&gt;But today’s version is better than the naive one I almost built.&lt;/p&gt;
&lt;p&gt;No overfetching on every event. No forcing the dot off on refresh failure. No duplicated client/vendor notification models. No pretending message notifications are the same as booking/payment/account events when the app already treats chat specially.&lt;/p&gt;
&lt;p&gt;All of that for a red dot.&lt;/p&gt;
&lt;p&gt;A very small red dot.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15394/the-tiny-red-dot-that-wasnt-tiny</guid>
<pubDate>Tue, 21 Apr 2026 13:17:28 +0000</pubDate>
</item>
<item>
<title>The Message That Was There, But Not There</title>
<link>https://coderlegion.com/15393/the-message-that-was-there-but-not-there</link>
<description>&lt;p&gt;Today I worked on one of those bugs that sounds suspiciously simple when someone describes it.&lt;/p&gt;
&lt;p&gt;&quot;When a new message comes in, the message screen updates. But when I open the chat, the message is not there. If I go back, refresh, and open it again, then it shows.&quot;&lt;/p&gt;
&lt;p&gt;That sentence has all the ingredients of a quiet afternoon turning into a long stare at state management.&lt;/p&gt;
&lt;p&gt;The app has a fairly normal chat flow. There is a messages screen with conversation previews, unread counts, recent chats, and the latest message text. Then there is a chat screen that shows the actual thread. Messages arrive through a socket. Push or foreground notifications can also show a snackbar. The backend stores read status, and the app marks messages as read once the user opens the chat.&lt;/p&gt;
&lt;p&gt;Nothing exotic. Which, of course, is exactly why it was interesting.&lt;/p&gt;
&lt;h3 id=&quot;the-preview-was-telling-the-truth&quot;&gt;The preview was telling the truth&lt;/h3&gt;
&lt;p&gt;The first clue was that the message list did update.&lt;/p&gt;
&lt;p&gt;When a message arrived, the preview changed. The unread count changed. The notification snackbar appeared. So this was not a socket connection issue, not a missing event, and not a routing issue.&lt;/p&gt;
&lt;p&gt;The list knew about the new message.&lt;/p&gt;
&lt;p&gt;But the chat screen did not.&lt;/p&gt;
&lt;p&gt;That narrowed the shape of the bug quite a bit. Somewhere between &quot;socket event reached the messages screen&quot; and &quot;chat screen opened&quot;, the data was getting thinner.&lt;/p&gt;
&lt;p&gt;The messages screen had a &lt;code&gt;ChatPreview&lt;/code&gt; model. It carried the things you expect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ChatPreview {
  final String latestMessage;
  final int unreadCount;
  final String userId;
  final String? conversationId;
  final List&amp;lt;ChatMessage&amp;gt; initialMessages;

  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last field was the important one: &lt;code&gt;initialMessages&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The chat screen was not always fetching a fresh thread immediately when opened. It was initialized from the preview object:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;_messages = widget.initialMessages ?? [];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the list screen was not just a list screen. It was also acting as a lightweight cache for the first render of the chat screen.&lt;/p&gt;
&lt;p&gt;That is a reasonable design. It makes the chat open quickly. It avoids a blank screen while waiting for the network. It also means the cache has to be correct.&lt;/p&gt;
&lt;p&gt;And mine was not.&lt;/p&gt;
&lt;h3 id=&quot;the-tiny-split-brain&quot;&gt;The tiny split-brain&lt;/h3&gt;
&lt;p&gt;The socket handler on the messages screen was doing something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;existing.copyWith(
  latestMessage: latestMessage,
  time: timestamp,
  unreadCount: existing.unreadCount + 1,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That made the preview look right.&lt;/p&gt;
&lt;p&gt;But it did not add the new socket message to &lt;code&gt;initialMessages&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the user saw the new message in the list, tapped the conversation, and the chat screen opened with the old cached message list. The newest message was effectively living in the preview text, but not in the actual message collection.&lt;/p&gt;
&lt;p&gt;That explains the weird part where the message appeared only after a manual refresh. The refresh pulled the full conversation again from the backend, rebuilt &lt;code&gt;initialMessages&lt;/code&gt;, and finally the chat screen had the missing message.&lt;/p&gt;
&lt;p&gt;It also explained the unread count behaving oddly. Opening the chat locally cleared the unread count in the list UI, but the chat screen could not mark that new message as read because the message was not in &lt;code&gt;_messages&lt;/code&gt;. After refresh, the backend still considered it unread. That was not the backend being stubborn. It was the UI never giving the read-marking code the message id it needed.&lt;/p&gt;
&lt;p&gt;This is the kind of bug where every individual piece is doing something understandable, but the system as a whole lies to you.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-was-not-to-refresh-harder&quot;&gt;The fix was not to refresh harder&lt;/h3&gt;
&lt;p&gt;The tempting fix would have been: &quot;Just fetch the whole conversation every time the chat opens.&quot;&lt;/p&gt;
&lt;p&gt;That would probably work. It would also make the app feel a little heavier, especially in a realtime chat where the socket already handed us the message. Pulling from the backend after receiving the exact event felt like paying twice for the same information.&lt;/p&gt;
&lt;p&gt;The better fix was to make the socket path update both layers of state:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;the visible preview fields&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the cached &lt;code&gt;initialMessages&lt;/code&gt; passed into the chat screen&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So when a socket message arrives, I now build a real &lt;code&gt;ChatMessage&lt;/code&gt; from the socket payload:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final incomingMessage = ChatMessage(
  id: messageId,
  message: content,
  timestamp: timestamp,
  isSender: senderId == currentUserId,
  messageType: resolvedMessageType,
  attachmentIds: attachmentIds,
  tempId: tempId,
  status: status,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I merge it into the preview's cached messages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;List&amp;lt;ChatMessage&amp;gt; mergeMessageIntoCache(
  List&amp;lt;ChatMessage&amp;gt; currentMessages,
  ChatMessage incomingMessage,
) {
  final nextMessages = List&amp;lt;ChatMessage&amp;gt;.from(currentMessages);

  final existingIndex = nextMessages.indexWhere(
    (message) =&amp;gt;
        message.id == incomingMessage.id ||
        (incomingMessage.tempId != null &amp;amp;&amp;amp;
            message.tempId == incomingMessage.tempId),
  );

  if (existingIndex == -1) {
    nextMessages.add(incomingMessage);
  } else {
    nextMessages[existingIndex] = incomingMessage;
  }

  nextMessages.sort((a, b) =&amp;gt; a.timestamp.compareTo(b.timestamp));
  return nextMessages;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;tempId&lt;/code&gt; part matters because optimistic UI is usually lurking somewhere nearby in chat apps. A sent message may appear locally before the server returns its final id. If the server later echoes it back, you do not want a duplicate bubble. Matching by &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;tempId&lt;/code&gt; keeps that handoff cleaner.&lt;/p&gt;
&lt;p&gt;Now the preview update includes the actual message cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;existing.copyWith(
  latestMessage: latestMessage,
  time: timestamp,
  unreadCount: shouldIncrement ? existing.unreadCount + 1 : 0,
  initialMessages: mergeMessageIntoCache(
    existing.initialMessages,
    incomingMessage,
  ),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result is less dramatic than the bug: the user taps the chat, the message is already there, and the read logic can do its job.&lt;/p&gt;
&lt;p&gt;Lovely when software becomes boring again.&lt;/p&gt;
&lt;h3 id=&quot;then-attachments-joined-the-conversation&quot;&gt;Then attachments joined the conversation&lt;/h3&gt;
&lt;p&gt;After that, another small issue showed up.&lt;/p&gt;
&lt;p&gt;Text messages were fine, but when a new image or voice note arrived, the live preview said:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Attachment&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;After a manual refresh, it correctly said:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Image&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Voice note&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Same screen. Same message. Different label depending on whether it came from the socket path or the refresh path.&lt;/p&gt;
&lt;p&gt;That pointed to another mismatch.&lt;/p&gt;
&lt;p&gt;The refresh path had more context. It could inspect or infer attachment metadata and resolve the message type properly. The socket path was mostly trusting the raw &lt;code&gt;type&lt;/code&gt; field from the event. If the type was missing, generic, lowercase, or not specific enough, the UI fell back to &lt;code&gt;Attachment&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is one of those details that is easy to miss because the fallback is not broken. &quot;Attachment&quot; is technically acceptable. It just feels unfinished, especially when the same message becomes &quot;Image&quot; after refresh.&lt;/p&gt;
&lt;p&gt;So the socket preview path needed to use the same resolver as the loaded conversation path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final attachmentIds = extractAttachmentIds(messageData);

final resolvedMessageType =
    attachmentIds.isNotEmpty
        ? await resolveMessageType(rawType, attachmentIds)
        : mapMessageType(rawType.toUpperCase());

final latestMessage =
    content.isNotEmpty
        ? content
        : labelForMessageType(resolvedMessageType);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That made the live preview and refreshed preview agree.&lt;/p&gt;
&lt;p&gt;The important thing here is not the label. It is the consistency of the data pipeline. If one path says &quot;Image&quot; and another says &quot;Attachment&quot;, users may not describe it as a data pipeline issue. They will just say, very fairly, &quot;This screen is weird.&quot;&lt;/p&gt;
&lt;h3 id=&quot;client-and-vendor-were-the-same-bug-twice&quot;&gt;Client and vendor were the same bug twice&lt;/h3&gt;
&lt;p&gt;This app has both client and vendor versions of the messages screen. They are not identical screens in the product sense, but their chat-preview logic is similar enough that fixing only one side would have been lazy.&lt;/p&gt;
&lt;p&gt;So the same cache merge and attachment preview fix went into both message screens.&lt;/p&gt;
&lt;p&gt;This is where duplicated UI logic starts tapping you on the shoulder.&lt;/p&gt;
&lt;p&gt;Duplication is not automatically evil. Sometimes two flows really do need room to evolve separately. But realtime chat logic is the kind of thing that gets expensive when copied around. Socket payload parsing, attachment normalization, message deduping, unread count handling, and read-status behavior are all easy to get almost-right in two places.&lt;/p&gt;
&lt;p&gt;Almost-right is where bugs breed.&lt;/p&gt;
&lt;p&gt;I did not do the larger refactor today, but the shape is obvious: this logic wants to live somewhere shared. Not necessarily a giant abstraction. Maybe just a small mapper/helper that turns socket payloads into &lt;code&gt;ChatMessage&lt;/code&gt; and preview metadata. Enough to stop the client and vendor screens from drifting apart.&lt;/p&gt;
&lt;p&gt;There is a difference between noticing a refactor and doing it immediately. Today the right move was to fix the bug tightly, verify both screens, and leave the larger cleanup as a clear next step.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-keep-thinking-about&quot;&gt;The part I keep thinking about&lt;/h3&gt;
&lt;p&gt;The bug was not caused by Flutter being strange. It was caused by state having two representations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;preview state&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;detail state&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The preview state changed first, so the UI looked alive. But the detail state stayed stale, so the next screen felt broken.&lt;/p&gt;
&lt;p&gt;That is a very mobile-app kind of bug.&lt;/p&gt;
&lt;p&gt;Mobile interfaces often optimize for immediacy. We cache, prefill, optimistic-update, debounce, hydrate, and subscribe to streams. All of that is normal. But once there are multiple ways for the same entity to enter the UI, each path has to preserve the same meaning.&lt;/p&gt;
&lt;p&gt;A message cannot only be a string in one path and a full object in another.&lt;/p&gt;
&lt;p&gt;An attachment cannot be &quot;whatever the raw event said&quot; in one path and &quot;resolved from metadata&quot; in another.&lt;/p&gt;
&lt;p&gt;Unread state cannot be cleared visually if the message id never reaches the code that persists the read receipt.&lt;/p&gt;
&lt;p&gt;None of these are huge architectural revelations. They are smaller than that, and maybe more useful because of it.&lt;/p&gt;
&lt;p&gt;Today's reminder: realtime UI bugs are often not about receiving data. They are about receiving data into the wrong layer of state.&lt;/p&gt;
&lt;p&gt;And yes, sometimes the message is there.&lt;/p&gt;
&lt;p&gt;Just not where the next screen is looking.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15393/the-message-that-was-there-but-not-there</guid>
<pubDate>Tue, 21 Apr 2026 13:16:42 +0000</pubDate>
</item>
<item>
<title>Flutter’s Execution Model — Explained Like a Human</title>
<link>https://coderlegion.com/14759/flutters-execution-model-explained-like-a-human</link>
<description>&lt;p&gt;If you’ve been building with Flutter for a while, you’ve definitely heard terms like &lt;strong&gt;AOT&lt;/strong&gt; and &lt;strong&gt;JIT&lt;/strong&gt; thrown around.&lt;/p&gt;
&lt;p&gt;Maybe you know debug is slower than release.&lt;br&gt;
Maybe you’ve wondered what actually ends up inside your APK.&lt;/p&gt;
&lt;p&gt;But like… &lt;em&gt;what’s really going on under the hood?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Let’s open it up properly — no buzzwords, no hand-wavy stuff. Just what actually happens to your code, when it happens, and why Flutter lowkey has a massive edge.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-simplest-way-to-think-about-it&quot;&gt;The Simplest Way to Think About It&lt;/h3&gt;
&lt;p&gt;Imagine you invited someone over for dinner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AOT (Ahead of Time)&lt;/strong&gt; is you cooking &lt;em&gt;everything&lt;/em&gt; beforehand.&lt;br&gt;
Full meal ready. Table set. Chef mode ON.&lt;/p&gt;
&lt;p&gt;They walk in → boom, food’s already there.&lt;/p&gt;
&lt;p&gt;But if they suddenly go:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Actually I feel like pasta instead of rice…”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;…yeah, you’re screwed XD&lt;br&gt;
You’re starting from scratch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;JIT (Just in Time)&lt;/strong&gt; is the opposite.&lt;/p&gt;
&lt;p&gt;You’re like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Tell me what you want, I’ll make it right now.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Flexible? Yes.&lt;br&gt;
Fast? …depends how hungry they are ;)&lt;/p&gt;
&lt;p&gt;Flutter basically looked at both and said:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Why not use both where they make sense?”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And that’s exactly what it does.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;debug-mode-jit-and-the-dart-vm&quot;&gt;Debug Mode — JIT and the Dart VM&lt;/h3&gt;
&lt;p&gt;When you run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your code is &lt;strong&gt;NOT&lt;/strong&gt; compiled to native machine code.&lt;/p&gt;
&lt;p&gt;Yeah — surprise.&lt;/p&gt;
&lt;p&gt;Instead, Dart takes a completely different route.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-compilation-pipeline&quot;&gt;The Compilation Pipeline&lt;/h3&gt;
&lt;p&gt;Your Dart code first goes through something called the &lt;strong&gt;CFE (Common Frontend)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Sounds fancy, but it’s basically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;parsing your code&lt;/li&gt;
&lt;li&gt;checking types&lt;/li&gt;
&lt;li&gt;resolving imports&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And then it produces something called a &lt;strong&gt;Kernel AST&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Think of it like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;not source code, not machine code… but a structured “meaning” of your program&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This gets saved as &lt;code&gt;.dill&lt;/code&gt; files (Dart Intermediate Language).&lt;/p&gt;
&lt;p&gt;From here, the &lt;strong&gt;Dart VM&lt;/strong&gt; takes over.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tiered-jit-this-is-where-it-gets-spicy&quot;&gt;Tiered JIT — This Is Where It Gets Spicy&lt;/h3&gt;
&lt;p&gt;The Dart VM doesn’t just compile everything upfront.&lt;/p&gt;
&lt;p&gt;Because that would be dumb.&lt;/p&gt;
&lt;p&gt;Why compile everything when half your code might never even run?&lt;/p&gt;
&lt;p&gt;So it uses &lt;strong&gt;tiers&lt;/strong&gt;:&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;tier-0-just-run-it-bro-&quot;&gt;Tier 0 — “Just run it bro”&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Runs in an interpreter&lt;/li&gt;
&lt;li&gt;No compilation&lt;/li&gt;
&lt;li&gt;Starts instantly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Downside? Slow af.&lt;/p&gt;
&lt;p&gt;But meanwhile, the VM is silently watching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;what functions are called&lt;/li&gt;
&lt;li&gt;what types are used&lt;/li&gt;
&lt;li&gt;which paths are hot&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;tier-1-okay-this-seems-important-&quot;&gt;Tier 1 — “Okay this seems important”&lt;/h4&gt;
&lt;p&gt;Once a function gets called enough times:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;it gets compiled to native code&lt;/li&gt;
&lt;li&gt;but not heavily optimized&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Faster than interpreter, but still warming up.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;tier-2-alright-i-know-your-patterns-now-&quot;&gt;Tier 2 — “Alright I know your patterns now”&lt;/h4&gt;
&lt;p&gt;This is where things get spicy!&lt;/p&gt;
&lt;p&gt;Now the VM has enough data to go:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Cool, I trust this pattern.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So it starts making &lt;strong&gt;bold assumptions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;add(a, b)&lt;/code&gt; has only ever seen integers…&lt;/p&gt;
&lt;p&gt;It goes:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“I’m skipping type checks. We going full speed.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But what if suddenly a &lt;code&gt;double&lt;/code&gt; shows up?&lt;/p&gt;
&lt;p&gt;Boom.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deoptimization.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;throws away optimized code&lt;/li&gt;
&lt;li&gt;falls back&lt;/li&gt;
&lt;li&gt;re-learns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No drama. Just adapts.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-real-trick&quot;&gt;The Real Trick&lt;/h4&gt;
&lt;p&gt;Cold code → stays cheap&lt;br&gt;
Hot code → gets optimized like crazy&lt;/p&gt;
&lt;p&gt;You only pay cost where it actually matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hot-reload-the-real-mvp&quot;&gt;Hot Reload — The Real MVP&lt;/h3&gt;
&lt;p&gt;This is where JIT absolutely flexes.&lt;/p&gt;
&lt;p&gt;You change a widget color → hit save.&lt;/p&gt;
&lt;p&gt;Here’s what happens behind the scenes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Only changed code gets recompiled&lt;/li&gt;
&lt;li&gt;Sent to Dart VM over WebSocket&lt;/li&gt;
&lt;li&gt;VM patches functions &lt;strong&gt;in-place&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Flutter re-runs &lt;code&gt;build()&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And here’s the crazy part:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your app state stays exactly the same&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scroll position? safe&lt;/li&gt;
&lt;li&gt;form input? safe&lt;/li&gt;
&lt;li&gt;counters? safe&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because state lives in memory (heap), and you’re only swapping instructions.&lt;/p&gt;
&lt;p&gt;That’s why hot reload feels like magic.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-debug-mode-feels-slow&quot;&gt;Why Debug Mode Feels Slow&lt;/h3&gt;
&lt;p&gt;It’s not just JIT.&lt;/p&gt;
&lt;p&gt;Debug mode has extra baggage:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No tree shaking → everything included&lt;/li&gt;
&lt;li&gt;Assertions ON → extra checks&lt;/li&gt;
&lt;li&gt;DevTools hooks running&lt;/li&gt;
&lt;li&gt;No aggressive optimizations&lt;/li&gt;
&lt;li&gt;Constant profiling&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Basically:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Debug mode = comfort + visibility&lt;br&gt;
Release mode = speed + violence&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;release-mode-aot-and-native-code&quot;&gt;Release Mode — AOT and Native Code&lt;/h3&gt;
&lt;p&gt;Now things change completely.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter build apk --release
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No VM games anymore.&lt;/p&gt;
&lt;p&gt;This is full &lt;strong&gt;AOT mode&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-compilation-pipeline-again-but-different-ending-&quot;&gt;The Compilation Pipeline (Again, But Different Ending)&lt;/h3&gt;
&lt;p&gt;Same start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dart → CFE → Kernel AST&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But now instead of VM → it goes to:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gen_snapshot&lt;/code&gt; (AOT compiler)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;type-flow-analysis-the-big-brain-move&quot;&gt;Type Flow Analysis — The Big Brain Move&lt;/h3&gt;
&lt;p&gt;Unlike JIT, AOT &lt;strong&gt;cannot guess&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;No safety net. No “fix later”.&lt;/p&gt;
&lt;p&gt;So it does something called &lt;strong&gt;Type Flow Analysis (TFA)&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;traces every possible path in your app&lt;/li&gt;
&lt;li&gt;figures out what types can exist where&lt;/li&gt;
&lt;li&gt;removes anything unreachable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now the compiler can actually be bold:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inline functions&lt;/li&gt;
&lt;li&gt;remove dead code&lt;/li&gt;
&lt;li&gt;skip dynamic checks&lt;/li&gt;
&lt;li&gt;devirtualize calls&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Result?&lt;/p&gt;
&lt;p&gt;Extremely optimised native machine code&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-inside-your-apk-&quot;&gt;What’s Inside Your APK?&lt;/h3&gt;
&lt;p&gt;If you unzip it, you’ll see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;your_app.apk
├── lib/
│   ├── arm64-v8a/
│   │   ├── libapp.so        ← YOUR code (compiled)
│   │   └── libflutter.so    ← Flutter engine
├── assets/
├── AndroidManifest.xml
└── classes.dex              ← tiny Android entry point
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;classes.dex&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;Not your app logic.&lt;/p&gt;
&lt;p&gt;It’s just Android saying:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Okay Flutter, you take over now.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After that:&lt;br&gt;
--&amp;gt;Flutter engine loads &lt;code&gt;libapp.so&lt;/code&gt;&lt;br&gt;
--&amp;gt; CPU runs your code directly&lt;/p&gt;
&lt;p&gt;No VM. No interpreter. No waiting.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-hot-reload-is-impossible-here&quot;&gt;Why Hot Reload Is Impossible Here&lt;/h3&gt;
&lt;p&gt;In release mode:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no Dart VM&lt;/li&gt;
&lt;li&gt;no runtime patching&lt;/li&gt;
&lt;li&gt;no WebSocket updates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It’s just static native code.&lt;/p&gt;
&lt;p&gt;So yeah…&lt;/p&gt;
&lt;p&gt;--&amp;gt; Want changes? Rebuild. Reinstall. Done.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;profile-mode-quick-side-note&quot;&gt;Profile Mode — Quick Side Note&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter run --profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the middle ground:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AOT performance&lt;/li&gt;
&lt;li&gt;but still profiling tools available&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use it when you want &lt;strong&gt;real performance numbers&lt;/strong&gt; without debug slowdown.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-flutter-hits-different&quot;&gt;Why Flutter Hits Different&lt;/h3&gt;
&lt;p&gt;This is where things get really interesting.&lt;/p&gt;
&lt;h4 id=&quot;react-native-&quot;&gt;React Native:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;JS runs in the engine&lt;/li&gt;
&lt;li&gt;communicates via &lt;strong&gt;bridge&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;data serialized/deserialized&lt;/li&gt;
&lt;li&gt;async overhead&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;flutter-&quot;&gt;Flutter:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Dart → native machine code&lt;/li&gt;
&lt;li&gt;talks directly to rendering engine&lt;/li&gt;
&lt;li&gt;draws everything itself&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No bridge. No middleman.&lt;/p&gt;
&lt;p&gt;Flutter literally went:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“We’ll just render everything ourselves.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And honestly?&lt;/p&gt;
&lt;p&gt;Yeah… kinda insane.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mental-model&quot;&gt;The Mental Model&lt;/h3&gt;
&lt;p&gt;If you take one thing away, let it be this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debug mode → Dart VM is alive&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;dynamic&lt;/li&gt;
&lt;li&gt;patchable&lt;/li&gt;
&lt;li&gt;flexible&lt;/li&gt;
&lt;li&gt;hot reload&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Release mode → Dart VM is gone&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;static&lt;/li&gt;
&lt;li&gt;precompiled&lt;/li&gt;
&lt;li&gt;fast af&lt;/li&gt;
&lt;li&gt;no flexibility&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Flutter gives you both.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JIT for development speed&lt;/li&gt;
&lt;li&gt;AOT for runtime performance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Compilation never disappears.&lt;/p&gt;
&lt;p&gt;It just moves to where it makes the most sense.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14759/flutters-execution-model-explained-like-a-human</guid>
<pubDate>Tue, 14 Apr 2026 08:50:35 +0000</pubDate>
</item>
<item>
<title>From Spaghetti to Structure: Why I Migrated My Production Flutter App to Clean Architecture</title>
<link>https://coderlegion.com/13790/from-spaghetti-to-structure-why-migrated-my-production-flutter-app-to-clean-architecture</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;&quot;First make it work, then make it beautiful and robust.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I'm a junior dev. I built a real app — &lt;a rel=&quot;nofollow&quot; href=&quot;https://anahad.space&quot;&gt;Anahad&lt;/a&gt; — that real people use. And for a while, I was proud of it.&lt;/p&gt;
&lt;p&gt;Then it grew. And I wasn't so proud anymore.&lt;/p&gt;
&lt;p&gt;This isn't a textbook post about Clean Architecture. This is the story of what broke, why I finally made the switch, and what actually clicked for me — told by someone who was &lt;em&gt;in the trenches&lt;/em&gt; with a messy codebase and had to ship features at the same time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mess-i-was-living-in&quot;&gt;The Mess I Was Living In&lt;/h3&gt;
&lt;p&gt;Picture this: 6 features. Each with their own screens, widgets, blocs, services, and utils — all distributed across a directory structure that looked neat on day one and became a labyrinth by month three.&lt;/p&gt;
&lt;p&gt;Most screens were monoliths — all-in-one files with UI and logic tangled together. The ones that &lt;em&gt;did&lt;/em&gt; use components? Everything went into a global &lt;code&gt;widgets/&lt;/code&gt; directory. So you'd have prop drilling across three levels just to pass something that one widget needed. Tracking the flow of data meant hopping between directories, mentally stitching together a picture of how things connected.&lt;/p&gt;
&lt;p&gt;Error handling was... creative. I'd built my own &lt;code&gt;AsyncResult&amp;lt;Success, Error&amp;gt;&lt;/code&gt; sealed class — functional programming vibes without the actual library. It worked at the service layer, but it was an island. Nothing else was consistent.&lt;/p&gt;
&lt;p&gt;And the worst part? My services were tightly coupled to Dio and the remote API. No abstraction. No interface. You couldn't mock anything. You couldn't test anything. If Dio failed, the whole thing failed, and you had to &lt;em&gt;dig&lt;/em&gt; to figure out why.&lt;/p&gt;
&lt;p&gt;The moment I truly felt it was when I had to trace a bug and found myself drilling through 5 directories just to understand the flow. That's when I knew — this isn't scaling. This is surviving.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-flutter-clean-architecture-actually-&quot;&gt;What Is Flutter Clean Architecture, Actually?&lt;/h3&gt;
&lt;p&gt;Before I get into the migration, let me walk you through the structure — because once it clicked, I couldn't unsee it.&lt;/p&gt;
&lt;p&gt;Clean Architecture splits your app into three layers:&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qjfcdg1syp5lm4wml4r4.png&quot; alt=&quot;Flutter clean architecture diagram&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;presentation-layer&quot;&gt;Presentation Layer&lt;/h4&gt;
&lt;p&gt;This is everything the user sees and touches.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Screens&lt;/strong&gt; — Your UI pages&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Widgets&lt;/strong&gt; — Reusable UI components&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bloc&lt;/strong&gt; — State management; the brain between UI and business logic&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Bloc &lt;em&gt;only&lt;/em&gt; talks to the Domain layer. It doesn't know where data comes from. Doesn't care.&lt;/p&gt;
&lt;h4 id=&quot;domain-layer-1&quot;&gt;Domain Layer&lt;/h4&gt;
&lt;p&gt;This is the pure heart of your app. No Flutter. No Dio. No Firebase. Just Dart.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Entities&lt;/strong&gt; — Plain Dart classes representing your core data models&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use Cases&lt;/strong&gt; — Single-responsibility classes that each do &lt;em&gt;one thing&lt;/em&gt; (e.g., &lt;code&gt;LoginUseCase&lt;/code&gt;, &lt;code&gt;FetchUserProfileUseCase&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository (Interface)&lt;/strong&gt; — Abstract contracts defining &lt;em&gt;what&lt;/em&gt; data operations exist, not &lt;em&gt;how&lt;/em&gt; they work&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This layer has zero dependencies on anything external. It's the most stable, most testable code in your app.&lt;/p&gt;
&lt;h4 id=&quot;-data-layer&quot;&gt;️ Data Layer&lt;/h4&gt;
&lt;p&gt;This is where the real world connects.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Models&lt;/strong&gt; — Extend your Entities; handle JSON serialization, API mapping&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository (Implementation)&lt;/strong&gt; — Implements the Domain's repository interface&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Sources&lt;/strong&gt; — Remote (Dio, Firebase) and Local (Hive, SQLite)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The magic line? &lt;strong&gt;The Repository Implementation delegates data fetching to the datasource.&lt;/strong&gt; The domain doesn't care if it's coming from the internet or a local cache.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Bloc → UseCase → Repository Interface
                        ↑
              Repository Implementation → Remote / Local Datasource
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-migration-incremental-not-a-rewrite&quot;&gt;The Migration: Incremental, Not a Rewrite&lt;/h3&gt;
&lt;p&gt;Here's the thing nobody tells you: you don't have to nuke your codebase.&lt;/p&gt;
&lt;p&gt;I had features to ship. Users on the app. I couldn't just disappear for two weeks and &quot;do clean architecture.&quot; So I went incremental.&lt;/p&gt;
&lt;p&gt;I started with the &lt;strong&gt;auth flow&lt;/strong&gt; — the most self-contained, most critical part of the app. My service methods were already well-written, so the migration was mostly structural:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Converted service methods into &lt;strong&gt;Use Cases&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Moved Dio calls into a &lt;code&gt;RemoteDataSource&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Created a &lt;code&gt;Repository Implementation&lt;/code&gt; that satisfied the Domain's abstract interface&lt;/li&gt;
&lt;li&gt;Wired the Bloc to talk to the Use Case instead of the service directly&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It wasn't that hard, honestly. But the &lt;em&gt;payoff&lt;/em&gt; was immediate.&lt;/p&gt;
&lt;p&gt;Now if I want to add offline support to Anahad? I create a &lt;code&gt;LocalDataSource&lt;/code&gt;, and the Repository Implementation switches between them. The Bloc doesn't change. The Use Cases don't change. The Domain doesn't even know it happened.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;That's&lt;/em&gt; the beauty.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fpdart-and-functional-programming-in-use-cases&quot;&gt;fpdart and Functional Programming in Use Cases&lt;/h3&gt;
&lt;p&gt;Coming from a Rust background, I was already comfortable with &lt;code&gt;Option&lt;/code&gt; and &lt;code&gt;Result&lt;/code&gt; types. I'd even hacked together my own &lt;code&gt;AsyncResult&amp;lt;Success, Error&amp;gt;&lt;/code&gt; sealed class before this migration.&lt;/p&gt;
&lt;p&gt;When I discovered &lt;strong&gt;fpdart&lt;/strong&gt;, it was like: &lt;em&gt;oh, this is just the proper version of what I was already doing.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In my Use Cases, I now use &lt;code&gt;Either&amp;lt;Failure, Success&amp;gt;&lt;/code&gt; for all return types. Left is failure, right is success — and you chain transformations cleanly without nested try-catches or nullable hell.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Use Case
class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future&amp;lt;Either&amp;lt;Failure, User&amp;gt;&amp;gt; call(LoginParams params) async {
    return await repository.login(params.email, params.password);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// In the Bloc
final result = await loginUseCase(LoginParams(email: email, password: password));

result.fold(
  (failure) =&amp;gt; emit(AuthError(failure.message)),
  (user)    =&amp;gt; emit(AuthSuccess(user)),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No try-catch spaghetti. No &quot;did this return null or did it actually fail?&quot; ambiguity. The types tell the story.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-aha-moment&quot;&gt;The &quot;Aha&quot; Moment&lt;/h3&gt;
&lt;p&gt;I'll be honest — my first reaction to Clean Architecture was &lt;em&gt;&quot;this is way too much boilerplate.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Why write a Use Case class that just calls a repository method? Why create an abstract interface when I could just use the implementation directly? Seemed like over-engineering for the sake of it.&lt;/p&gt;
&lt;p&gt;Then I migrated the auth flow. And something just... clicked.&lt;/p&gt;
&lt;p&gt;I was looking at my Bloc, and I realized: &lt;strong&gt;this Bloc has no idea where the user data comes from.&lt;/strong&gt; It doesn't import Dio. It doesn't know about Firebase. It just calls &lt;code&gt;loginUseCase()&lt;/code&gt; and reacts to the result.&lt;/p&gt;
&lt;p&gt;Before this, I couldn't test anything because everything was tightly coupled. The service needed Dio, Dio needed a real network, the network needed... you get it. Now, because the Repository is an abstract interface, I can mock it in tests trivially:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class MockAuthRepository extends Mock implements AuthRepository {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. Inject it. Test your Use Cases in complete isolation.&lt;/p&gt;
&lt;p&gt;Ambiguity went away. I know &lt;em&gt;exactly&lt;/em&gt; what each file does, what it depends on, and what it's responsible for. When something breaks, I know which layer to look at.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;should-you-use-clean-architecture-for-every-app-&quot;&gt;Should You Use Clean Architecture for Every App?&lt;/h3&gt;
&lt;p&gt;No. Please don't.&lt;/p&gt;
&lt;p&gt;If you're building a weekend project or learning Flutter for the first time, don't start with Clean Architecture. You'll spend more time setting up the structure than building the thing, and you'll learn &lt;em&gt;why&lt;/em&gt; the architecture exists without ever feeling the pain it solves.&lt;/p&gt;
&lt;p&gt;The pain is the teacher.&lt;/p&gt;
&lt;p&gt;Build the app. Ship it. Let it grow. Feel the moment where you're drilling through 5 directories to trace a bug, where you can't mock a thing for testing, where adding one feature means touching 8 files in 6 different folders.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Then&lt;/em&gt; reach for Clean Architecture. Because then you'll understand it — not just intellectually, but in your bones.&lt;/p&gt;
&lt;p&gt;The architecture exists to solve real problems. Let the problems teach you first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-reference-the-layer-cheat-sheet&quot;&gt;Quick Reference: The Layer Cheat Sheet&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Layer &lt;/th&gt;&lt;th&gt;Contains &lt;/th&gt;&lt;th&gt;Depends On&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Presentation &lt;/td&gt;&lt;td&gt;Screens, Widgets, Bloc &lt;/td&gt;&lt;td&gt;Domain only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Domain &lt;/td&gt;&lt;td&gt;Entities, Use Cases, Repository Interface &lt;/td&gt;&lt;td&gt;Nothing (pure Dart)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data &lt;/td&gt;&lt;td&gt;Models, Repository Impl, Data Sources &lt;/td&gt;&lt;td&gt;Domain interfaces&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-next-for-anahad&quot;&gt;What's Next for Anahad&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Migrating the remaining 5 features incrementally&lt;/li&gt;
&lt;li&gt;Adding a proper logging layer&lt;/li&gt;
&lt;li&gt;Writing actual unit tests now that mocking is possible&lt;/li&gt;
&lt;li&gt;Maybe offline support — and thanks to clean architecture, it'll be a datasource swap, not a rewrite&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;If you're a fellow junior dev with a production app that's starting to smell — I see you. You don't have to have everything figured out from day one. Build it, feel the pain, then build it better.&lt;/p&gt;
&lt;p&gt;That's not a failure. That's engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Built with love (and a lot of refactoring) while working on &lt;a rel=&quot;nofollow&quot; href=&quot;https://anahad.space&quot;&gt;Anahad&lt;/a&gt;.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Hit me up on Twitter/X if this resonated — always happy to talk Flutter, architecture, or why Rust spoils you for everything else.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13790/from-spaghetti-to-structure-why-migrated-my-production-flutter-app-to-clean-architecture</guid>
<pubDate>Wed, 01 Apr 2026 04:48:07 +0000</pubDate>
</item>
<item>
<title>Flutter Performance Optimization 2026 (Make Your App 10x Faster + Best Practices)</title>
<link>https://coderlegion.com/13332/flutter-performance-optimization-2026-make-your-app-10x-faster-best-practices</link>
<description>&lt;p&gt;Hey guys, Samuel here again. One of the crucial aspects of mobile app development is performance optimization to ensure the app is usable even on low-quality devices and delivers a very good user experience, which is what we will be looking into today.&lt;/p&gt;
&lt;p&gt;To optimize a Flutter application's performance, focus on minimizing unnecessary widget rebuilds, using efficient rendering techniques, and managing resources effectively. The key steps involve leveraging built-in tools for profiling, optimizing UI code, and improving data handling efficiency.&lt;/p&gt;
&lt;h3 id=&quot;performance-diagnosis&quot;&gt;Performance Diagnosis&lt;/h3&gt;
&lt;p&gt;Before optimizing, you must identify bottlenecks using the right tools:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Run in Profile Mode:&lt;/strong&gt; Always profile your app in profile mode on a physical device, as debug mode performance is not representative of the final release build.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter run --profile
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flutter DevTools:&lt;/strong&gt; Use the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.flutter.dev/tools/devtools&quot;&gt;Flutter DevTools&lt;/a&gt; suite (Performance, Memory, Network tabs) to analyze frame rendering times, CPU usage, memory allocation, and network latency.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance Overlay:&lt;/strong&gt; Use the built-in PerformanceOverlay widget or press 'P' in the terminal while running the app to see real-time UI and GPU thread frame times. Red bars indicate &quot;jank&quot; (dropped frames).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;MaterialApp(
  ...
  showPerformanceOverlay: true,
  ...
),
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;widget-and-ui-optimization&quot;&gt;Widget and UI Optimization&lt;/h3&gt;
&lt;p&gt;Most performance issues stem from inefficient UI code.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Minimize Widget Rebuilds:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - Use &lt;code&gt;const&lt;/code&gt; constructors for widgets that don't change to allow Flutter to reuse them at compile time, reducing runtime overhead.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;const Text(&quot;This widget would not rebuild&quot;),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; - Refactor large build methods into smaller, dedicated &lt;code&gt;StatelessWidget&lt;/code&gt; or &lt;code&gt;StatefulWidget&lt;/code&gt; classes to localize the scope of rebuilds.&lt;/p&gt;
&lt;p&gt; - Use state management solutions (like Provider, Riverpod, or BLoC) with Consumer or Selector to ensure only the necessary parts of the UI rebuild when the state changes.&lt;/p&gt;
&lt;p&gt; - Use &lt;code&gt;ValueListenableBuilder&lt;/code&gt; or &lt;code&gt;AnimatedBuilder&lt;/code&gt; for fine-grained updates instead of calling setState() on large widgets.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Optimize Lists and Animations:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - For long lists, use &lt;code&gt;ListView.builder&lt;/code&gt; (or &lt;code&gt;SliverList&lt;/code&gt;) to lazy load items, building them only when they become visible on screen.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ListView.builder(
  shrinkWrap: true,
  physics: const NeverScrollableScrollPhysics(),
  itemCount: 1,
  itemBuilder: (context, index) {
    return ListTile();
  },
),

// OR

 SliverList.builder(
  itemBuilder: (context, index) =&amp;gt;
      Container(height: 100),
 ),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; - Wrap complex, frequently animating widgets in a &lt;code&gt;RepaintBoundary&lt;/code&gt; to isolate their repainting from the rest of the UI.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;RepaintBoundary(child: ...),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; - Avoid using the Opacity widget directly for animations; prefer FadeTransition instead, as it's more performant.&lt;/p&gt;
&lt;h3 id=&quot;resource-and-data-management&quot;&gt;Resource and Data Management&lt;/h3&gt;
&lt;p&gt;Efficiently handle data, images, and heavy computations to prevent UI freezes.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image Handling:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - Use the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/cached_network_image&quot;&gt;cached_network_image&lt;/a&gt; package for images from the network to implement efficient caching and reduce network requests.&lt;/p&gt;
&lt;p&gt; - Compress images before bundling them with the app and loading them at optimal resolutions.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Asynchronous Operations:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - Use &lt;code&gt;async/await&lt;/code&gt;, &lt;code&gt;FutureBuilder&lt;/code&gt;, and &lt;code&gt;StreamBuilder&lt;/code&gt; to perform network requests and other I/O operations without blocking the main UI thread.&lt;/p&gt;
&lt;p&gt; - For CPU-intensive tasks (e.g., heavy data processing), use &lt;a rel=&quot;nofollow&quot; href=&quot;https://dart.dev/language/isolates&quot;&gt;Isolates&lt;/a&gt; via the compute function to run them on a separate thread.&lt;br&gt;
Memory Management:&lt;/p&gt;
&lt;p&gt; - Always dispose of controllers (&lt;code&gt;AnimationController&lt;/code&gt;, &lt;code&gt;TextEditingController&lt;/code&gt;, etc.), &lt;code&gt;StreamSubscription&lt;/code&gt; instances, and other resources in the &lt;code&gt;dispose()&lt;/code&gt; Method of your &lt;code&gt;StatefulWidget&lt;/code&gt; to prevent memory leaks.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Network Optimization:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - Implement data pagination for large datasets to avoid fetching all data at once.&lt;br&gt;
 - Cache API responses to reduce the number of network calls and improve perceived performance.&lt;/p&gt;
&lt;h3 id=&quot;app-size-and-build-optimizations&quot;&gt;App Size and Build Optimizations&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reduce App Size:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; - Remove unused assets and libraries (&quot;tree-shaking&quot;).&lt;br&gt;
 - Use the &lt;code&gt;flutter build apk --split-per-abi&lt;/code&gt; command to generate smaller APKs for different device architectures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enable Release Mode:&lt;/strong&gt; Always verify final performance using &lt;code&gt;flutter run --release&lt;/code&gt; or by building the app in release mode.&lt;/p&gt;
&lt;p&gt;That's it! You've fully optimized your Flutter app performance if you follow all the principles. If you have any questions, make sure to drop them in the comment section.&lt;/p&gt;
&lt;p&gt;Join Free Newsletter: &lt;a rel=&quot;nofollow&quot; href=&quot;https://techwithsam.dev/newsletter&quot;&gt;https://techwithsam.dev/newsletter&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Samuel Adekunle, Tech With Sam &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtube.com/@techwithsam&quot;&gt;YouTube&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/13332/flutter-performance-optimization-2026-make-your-app-10x-faster-best-practices</guid>
<pubDate>Sun, 22 Mar 2026 20:11:53 +0000</pubDate>
</item>
<item>
<title>How a Bug Report Led Me to Give Flutter Developers Full Control of Crisp Chat Modals on iOS</title>
<link>https://coderlegion.com/12886/how-a-bug-report-led-me-to-give-flutter-developers-full-control-of-crisp-chat-modals-on-ios</link>
<description>&lt;p&gt;One of the things I enjoy most about maintaining open-source software is that sometimes the best improvements start with something very small.&lt;/p&gt;
&lt;p&gt;A simple bug report.&lt;br&gt;
A curious investigation.&lt;br&gt;
And sometimes… a new feature idea.&lt;/p&gt;
&lt;p&gt;This story started exactly like that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-bug-report-that-started-everything&quot;&gt;The Bug Report That Started Everything&lt;/h3&gt;
&lt;p&gt;While reviewing issues for the Flutter Crisp Chat plugin, I noticed a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat/issues/123&quot;&gt;report&lt;/a&gt; opened by &lt;strong&gt;Nikita Alexeev&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The title immediately caught my attention:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Taps pass through chat modal to underlying UI on iOS 13+.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sounded strange.&lt;/p&gt;
&lt;p&gt;A modal should block interactions with the UI behind it. So how could taps reach the Flutter app underneath?&lt;/p&gt;
&lt;p&gt;I opened the issue and read the details carefully.&lt;/p&gt;
&lt;p&gt;The behavior was simple but frustrating:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open the Crisp chat on an iPhone&lt;/li&gt;
&lt;li&gt;Tap near the top area of the chat&lt;/li&gt;
&lt;li&gt;Suddenly the Flutter UI behind it reacts&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Buttons could be pressed. Navigation could trigger. All while the chat was still visible.&lt;/p&gt;
&lt;p&gt;That’s definitely not the experience anyone wants when they open a support chat.&lt;/p&gt;
&lt;p&gt;So I started digging.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learning-something-interesting-about-ios&quot;&gt;Learning Something Interesting About iOS&lt;/h3&gt;
&lt;p&gt;While reading the issue discussion and the explanation from Nikita Alexeev, I discovered something interesting about iOS.&lt;/p&gt;
&lt;p&gt;Starting with &lt;strong&gt;iOS 13&lt;/strong&gt;, Apple changed how modal presentations behave by default.&lt;/p&gt;
&lt;p&gt;Before iOS 13, most modals were presented using:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.fullScreen&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Which means the modal covers the entire screen. Nothing behind it is visible, and more importantly, nothing behind it can receive touches.&lt;/p&gt;
&lt;p&gt;But iOS 13 introduced a new default style:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.pageSheet&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This creates the modern sheet-style UI where the modal slides up from the bottom and leaves part of the background visible.&lt;/p&gt;
&lt;p&gt;It looks elegant and more native to modern iOS design.&lt;/p&gt;
&lt;p&gt;But there was a catch.&lt;/p&gt;
&lt;p&gt;In this case, the dimmed background area was still allowing touch events to reach the Flutter UI underneath.&lt;/p&gt;
&lt;p&gt;That explained exactly why users were accidentally triggering buttons behind the chat window.&lt;/p&gt;
&lt;p&gt;Suddenly the bug made perfect sense.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-pull-request-that-fixed-it&quot;&gt;The Pull Request That Fixed It&lt;/h3&gt;
&lt;p&gt;Soon after reporting the issue, Nikita Alexeev submitted a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat/pull/124&quot;&gt;pull request&lt;/a&gt; with a very straightforward fix.&lt;/p&gt;
&lt;p&gt;Instead of relying on the default modal style, he forced the chat view controller to use full screen presentation.&lt;/p&gt;
&lt;p&gt;The change looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let chatVC = ChatViewController()
chatVC.modalPresentationStyle = .fullScreen
viewController.present(chatVC, animated: true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was a very small change, but it solved the problem completely.&lt;/p&gt;
&lt;p&gt;By forcing &lt;code&gt;.fullScreen&lt;/code&gt;, the chat now covered the entire screen again, which meant no touches could leak through to the Flutter UI behind it.&lt;/p&gt;
&lt;p&gt;The issue was fixed.&lt;/p&gt;
&lt;p&gt;But while reviewing the pull request, a new idea came to my mind.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-moment-the-idea-appeared&quot;&gt;The Moment the Idea Appeared&lt;/h3&gt;
&lt;p&gt;Yes, &lt;code&gt;.fullScreen&lt;/code&gt; solved the issue.&lt;/p&gt;
&lt;p&gt;But iOS actually supports several modal presentation styles.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.fullScreen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.pageSheet&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.formSheet&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.overFullScreen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.overCurrentContext&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.popover&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Different apps use different modal styles depending on their design.&lt;/p&gt;
&lt;p&gt;Some apps prefer full screen.&lt;/p&gt;
&lt;p&gt;Some prefer sheet-style modals.&lt;/p&gt;
&lt;p&gt;Some prefer overlays.&lt;/p&gt;
&lt;p&gt;So instead of hardcoding &lt;code&gt;.fullScreen&lt;/code&gt;, I thought:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if Flutter developers could choose the modal style themselves?&lt;br&gt;
&lt;/strong&gt;&lt;br&gt;
That way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The touch-through bug stays fixed&lt;/li&gt;
&lt;li&gt;Developers get flexibility&lt;/li&gt;
&lt;li&gt;The plugin becomes more powerful&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s when the idea shifted from a &lt;strong&gt;bug fix&lt;/strong&gt; to a &lt;strong&gt;developer-focused feature&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;turning-the-idea-into-a-feature&quot;&gt;Turning the Idea into a Feature&lt;/h3&gt;
&lt;p&gt;To make this possible, I needed to expose the modal presentation style all the way from Flutter to the native iOS implementation.&lt;/p&gt;
&lt;p&gt;So I started with the Flutter configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-extending-the-flutter-configuration&quot;&gt;Step 1 — Extending the Flutter Configuration&lt;/h3&gt;
&lt;p&gt;First, I added a new enum to represent the iOS modal styles.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum ModalPresentationStyle {
  fullScreen,
  pageSheet,
  formSheet,
  overFullScreen,
  overCurrentContext,
  popover,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I added it to the &lt;code&gt;CrispConfig&lt;/code&gt; class.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class CrispConfig {
  final String websiteID;
  final String? tokenId;
  final ModalPresentationStyle? modalPresentationStyle;

  CrispConfig({
    required this.websiteID,
    this.tokenId,
    this.modalPresentationStyle,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Flutter developers could specify how the chat modal should appear.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-bridging-the-setting-to-ios&quot;&gt;Step 2 — Bridging the Setting to iOS&lt;/h3&gt;
&lt;p&gt;Next, I needed to convert that Flutter value into a native iOS presentation style.&lt;/p&gt;
&lt;p&gt;Inside the Swift plugin code, I mapped the configuration to &lt;code&gt;UIModalPresentationStyle&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;switch modalPresentationStyleString {
case &quot;fullScreen&quot;:
    modalPresentationStyle = .fullScreen
case &quot;pageSheet&quot;:
    modalPresentationStyle = .pageSheet
case &quot;formSheet&quot;:
    modalPresentationStyle = .formSheet
case &quot;overFullScreen&quot;:
    modalPresentationStyle = .overFullScreen
case &quot;overCurrentContext&quot;:
    modalPresentationStyle = .overCurrentContext
case &quot;popover&quot;:
    modalPresentationStyle = .popover
default:
    modalPresentationStyle = .fullScreen
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ensured a safe default while still allowing flexibility.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-applying-the-configuration&quot;&gt;Step 3 — Applying the Configuration&lt;/h3&gt;
&lt;p&gt;Finally, instead of hardcoding &lt;code&gt;.fullScreen&lt;/code&gt;, the plugin now uses the configured style.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let chatVC = ChatViewController()
chatVC.modalPresentationStyle = crispConfig.modalPresentationStyle

viewController.present(chatVC, animated: true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Flutter developers have full control over the presentation style.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;making-the-feature-easy-to-discover&quot;&gt;Making the Feature Easy to Discover&lt;/h3&gt;
&lt;p&gt;After implementing the improvements, I wanted to make sure developers could easily explore and understand them.&lt;/p&gt;
&lt;p&gt;So I also improved the developer experience around the plugin.&lt;/p&gt;
&lt;p&gt;I added an &lt;strong&gt;interactive demo inside the example app&lt;/strong&gt; where developers can test different iOS modal styles.&lt;/p&gt;
&lt;p&gt;There’s a button called “&lt;strong&gt;iOS Modal Style Demo&lt;/strong&gt;” that opens the chat using different presentation styles so developers can see how each one behaves.&lt;/p&gt;
&lt;p&gt;I also updated the documentation to clearly explain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How iOS modal presentation works&lt;/li&gt;
&lt;li&gt;Why .fullScreen fixes the touch-through issue&lt;/li&gt;
&lt;li&gt;How to configure the modal style in Flutter&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-developers-gain-from-this&quot;&gt;What Developers Gain From This&lt;/h3&gt;
&lt;p&gt;What started as a small bug report now gives Flutter developers several benefits.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Better User Experience&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No more accidental taps on the UI behind the chat.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flexible Modal Presentation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Developers can choose the modal style that fits their app design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Platform Awareness&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These changes only affect iOS behavior, while Android remains unchanged.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-love-about-open-source&quot;&gt;What I Love About Open Source&lt;/h3&gt;
&lt;p&gt;This experience reminded me why I enjoy maintaining open-source projects.&lt;/p&gt;
&lt;p&gt;Sometimes improvements don’t start with big feature ideas.&lt;/p&gt;
&lt;p&gt;They start with a simple issue report.&lt;/p&gt;
&lt;p&gt;A developer noticing something strange.&lt;/p&gt;
&lt;p&gt;A pull request proposing a fix.&lt;/p&gt;
&lt;p&gt;And a moment of curiosity that turns a bug fix into something better.&lt;/p&gt;
&lt;p&gt;So huge thanks to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aspid168&quot;&gt;Nikita Alexeev&lt;/a&gt; for reporting the issue and submitting the original fix that inspired this improvement.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try It Yourself&lt;/h3&gt;
&lt;p&gt;If you’re using the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/crisp_chat&quot;&gt;Flutter Crisp Chat&lt;/a&gt; plugin, you can try the new configuration like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final config = CrispConfig(
  websiteID: 'YOUR_WEBSITE_ID',
  modalPresentationStyle: ModalPresentationStyle.pageSheet,
  enableNotifications: false,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And if you’re curious to see how each modal style behaves, check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat&quot;&gt;example&lt;/a&gt; app in the repository.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Sometimes the best features don’t start with a roadmap.&lt;/p&gt;
&lt;p&gt;They start with a bug report…&lt;br&gt;
and a little curiosity.&lt;/p&gt;
</description>
<guid isPermaLink="true">https://coderlegion.com/12886/how-a-bug-report-led-me-to-give-flutter-developers-full-control-of-crisp-chat-modals-on-ios</guid>
<pubDate>Sun, 15 Mar 2026 07:25:41 +0000</pubDate>
</item>
<item>
<title>The Hidden Gem of Flutter Lists: Unlocking scrollable_positioned_list's Superpowers</title>
<link>https://coderlegion.com/11390/the-hidden-gem-of-flutter-lists-unlocking-scrollablepositionedlists-superpowers</link>
<description>&lt;p&gt;As Flutter developers, we've all faced the common challenge: building lists that need more control than the standard &lt;code&gt;ListView&lt;/code&gt; provides. Whether it's scrolling to a specific index, tracking which items are visible, or implementing complex navigation patterns, Flutter's built-in widgets sometimes leave us wanting more.&lt;/p&gt;
&lt;p&gt;Enter &lt;strong&gt;scrollable_positioned_list&lt;/strong&gt; – a powerful yet often-overlooked package from Google's flutter.widgets repository that solves these problems elegantly.&lt;/p&gt;
&lt;p&gt;In this comprehensive guide, we'll explore the hidden gems of this package and demonstrate how it can transform your Flutter applications.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-scrollable-positioned-list-matters&quot;&gt;Why scrollable_positioned_list Matters&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-standard-listview&quot;&gt;The Problem with Standard ListView&lt;/h4&gt;
&lt;p&gt;Flutter's &lt;code&gt;ListView&lt;/code&gt; is excellent for basic scrolling, but it has significant limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No built-in &lt;code&gt;scrollToIndex()&lt;/code&gt;&lt;/strong&gt; – You must develop custom solutions to measure element offsets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No visibility detection&lt;/strong&gt; – Determining which items are currently visible requires complex workarounds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limited programmatic control&lt;/strong&gt; – Jumping to specific items is non-trivial&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-scrollable-positioned-list-solution&quot;&gt;The scrollable_positioned_list Solution&lt;/h4&gt;
&lt;p&gt;This package addresses all these limitations with a clean, well-documented API that maintains the familiar &lt;code&gt;ListView.builder&lt;/code&gt; pattern while adding powerful capabilities.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;According to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/documentation/scrollable_positioned_list/latest/&quot;&gt;official documentation&lt;/a&gt;&lt;/strong&gt;, this widget allows scrolling to a specific item and determining what items are currently visible – exactly what advanced Flutter applications need.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installation-and-setup&quot;&gt;Installation and Setup&lt;/h3&gt;
&lt;p&gt;Add the package to your &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  scrollable_positioned_list: ^0.3.8
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then import it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hidden-gem-1-programmatic-scrolling-with-scrolltoindex&quot;&gt;Hidden Gem #1: Programmatic Scrolling with scrollToIndex&lt;/h3&gt;
&lt;p&gt;The most powerful feature is the ability to scroll to any item programmatically – functionality that standard &lt;code&gt;ListView&lt;/code&gt; simply doesn't provide.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ScrollableListExample extends StatefulWidget {
  @override
  _ScrollableListExampleState createState() =&amp;gt; _ScrollableListExampleState();
}

class _ScrollableListExampleState extends State&amp;lt;ScrollableListExample&amp;gt; {
  final ItemScrollController _itemScrollController = ItemScrollController();
  final ItemPositionsNotifier _itemPositionsNotifier = ItemPositionsNotifier();
  final List&amp;lt;String&amp;gt; _items = List.generate(100, (index) =&amp;gt; 'Item ${index + 1}');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ScrollablePositionedList Demo')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                ElevatedButton(onPressed: () =&amp;gt; _scrollToIndex(0), child: const Text('To Start')),
                ElevatedButton(onPressed: () =&amp;gt; _scrollToIndex(_items.length - 1), child: const Text('To End')),
                ElevatedButton(onPressed: () =&amp;gt; _scrollToIndex(49), child: const Text('To Middle')),
              ],
            ),
          ),
          Expanded(
            child: ScrollablePositionedList.builder(
              itemCount: _items.length,
              itemScrollController: _itemScrollController,
              itemPositionsNotifier: _itemPositionsNotifier,
              itemBuilder: (context, index) =&amp;gt; ListTile(
                title: Text(_items[index]),
                subtitle: Text('Position: ${index + 1}'),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _scrollToIndex(int index) {
    _itemScrollController.scrollTo(index: index, duration: const Duration(seconds: 2), curve: Curves.easeInOutCubic);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Advanced Options:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Jump immediately (no animation)
void _jumpToIndex(int index) =&amp;gt; _itemScrollController.jumpTo(index: index);

// Scroll with custom alignment (0.0 = top, 0.5 = center, 1.0 = bottom)
void _scrollToCenter(int index) {
  _itemScrollController.scrollTo(index: index, alignment: 0.5, duration: const Duration(milliseconds: 500));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hidden-gem-2-itempositionlistener-tracking-what-s-visible&quot;&gt;Hidden Gem #2: ItemPositionListener – Tracking What's Visible&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;ItemPositionListener&lt;/code&gt; monitors which items are visible – perfect for reading progress, lazy loading, or scroll-synced animations.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ReadingProgressExample extends StatefulWidget {
  @override
  _ReadingProgressExampleState createState() =&amp;gt; _ReadingProgressExampleState();
}

class _ReadingProgressExampleState extends State&amp;lt;ReadingProgressExample&amp;gt; {
  final ItemPositionsNotifier _itemPositionsNotifier = ItemPositionsNotifier();
  double _readingProgress = 0.0;

  @override
  void initState() {
    super.initState();
    _itemPositionsNotifier.itemPositions.addListener(_updateProgress);
  }

  @override
  void dispose() {
    _itemPositionsNotifier.itemPositions.removeListener(_updateProgress);
    super.dispose();
  }

  void _updateProgress() {
    final positions = _itemPositionsNotifier.itemPositions.value;
    if (positions.isNotEmpty) {
      final minIndex = positions.where((p) =&amp;gt; p.itemLeadingEdge &amp;lt; 1).reduce((min, p) =&amp;gt; p.itemLeadingEdge &amp;lt; min.itemLeadingEdge ? p : min).index;
      final maxIndex = positions.where((p) =&amp;gt; p.itemTrailingEdge &amp;gt; 0).reduce((max, p) =&amp;gt; p.itemTrailingEdge &amp;gt; max.itemTrailingEdge ? p : max).index;
      setState(() =&amp;gt; _readingProgress = ((minIndex + maxIndex) / 200).clamp(0.0, 1.0));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Reading Progress'), bottom: PreferredSize(preferredSize: const Size.fromHeight(4), child: LinearProgressIndicator(value: _readingProgress))),
      body: ScrollablePositionedList.builder(itemCount: 100, itemPositionsNotifier: _itemPositionsNotifier, itemBuilder: (context, index) =&amp;gt; Padding(padding: const EdgeInsets.all(16.0), child: Text('Chapter ${index + 1}', style: const TextStyle(fontSize: 18)))),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Detecting Fully Visible Items:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;bool isItemFullyVisible(ItemPosition position) =&amp;gt; position.itemLeadingEdge &amp;gt;= 0 &amp;amp;&amp;amp; position.itemTrailingEdge &amp;lt;= 1;

List&amp;lt;int&amp;gt; getFullyVisibleIndices() =&amp;gt; _itemPositionsNotifier.itemPositions.value.where(isItemFullyVisible).map((p) =&amp;gt; p.index).toList();
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hidden-gem-3-variable-height-items-support&quot;&gt;Hidden Gem #3: Variable Height Items Support&lt;/h3&gt;
&lt;p&gt;This package handles items of varying heights gracefully – crucial for dynamic content apps.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class VariableHeightListExample extends StatelessWidget {
  final ItemScrollController _scrollController = ItemScrollController();
  final List&amp;lt;_ContentItem&amp;gt; _items = List.generate(50, (index) {
    final lines = (index % 5) + 1;
    return _ContentItem(title: 'Article ${index + 1}', content: 'This article has $lines lines.\n' * lines);
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Variable Height List'), actions: [
        IconButton(icon: const Icon(Icons.arrow_downward), onPressed: () =&amp;gt; _scrollController.scrollTo(index: _items.length - 1, duration: const Duration(seconds: 2)))
      ]),
      body: ScrollablePositionedList.builder(
        itemCount: _items.length,
        itemScrollController: _scrollController,
        itemBuilder: (context, index) =&amp;gt; Card(
          margin: const EdgeInsets.all(8),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              Text(_items[index].title, style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 8), Text(_items[index].content)
            ]),
          ),
        ),
      ),
    );
  }
}

class _ContentItem {
  final String title, content;
  _ContentItem({required this.title, required this.content});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hidden-gem-4-scroll-offset-tracking&quot;&gt;Hidden Gem #4: Scroll Offset Tracking&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ScrollOffsetExample extends StatefulWidget {
  @override
  _ScrollOffsetExampleState createState() =&amp;gt; _ScrollOffsetExampleState();
}

class _ScrollOffsetExampleState extends State&amp;lt;ScrollOffsetExample&amp;gt; {
  final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
  final ItemScrollController _scrollController = ItemScrollController();
  double _currentScrollOffset = 0.0;

  @override
  void initState() {
    super.initState();
    _positionsNotifier.itemPositions.addListener(() {
      final positions = _positionsNotifier.itemPositions.value;
      if (positions.isNotEmpty) setState(() =&amp;gt; _currentScrollOffset = positions.first.itemLeadingEdge);
    });
  }

  @override
  void dispose() {
    _positionsNotifier.itemPositions.removeListener(() {});
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Offset: ${_currentScrollOffset.toStringAsFixed(2)}')),
      body: Stack(
        children: [
          Positioned(top: -_currentScrollOffset * 0.5, left: 0, right: 0, child: Container(height: 200, decoration: BoxDecoration(gradient: LinearGradient(colors: [Colors.blue.shade200, Colors.purple.shade200])))),
          ScrollablePositionedList.builder(
            itemCount: 50,
            itemScrollController: _scrollController,
            itemPositionsNotifier: _positionsNotifier,
            padding: const EdgeInsets.only(top: 150),
            itemBuilder: (context, index) =&amp;gt; ListTile(title: Text('Item ${index + 1}'), subtitle: Text('Offset: ${_currentScrollOffset.toStringAsFixed(2)}')),
          ),
        ],
      ),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;hidden-gem-5-scroll-spy-navigation&quot;&gt;Hidden Gem #5: Scroll Spy Navigation&lt;/h3&gt;
&lt;p&gt;Implement a table of contents that highlights the current section – perfect for documentation sites.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ScrollSpyExample extends StatefulWidget {
  @override
  _ScrollSpyExampleState createState() =&amp;gt; _ScrollSpyExampleState();
}

class _ScrollSpyExampleState extends State&amp;lt;ScrollSpyExample&amp;gt; {
  final ItemScrollController _scrollController = ItemScrollController();
  final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
  final List&amp;lt;String&amp;gt; _sections = List.generate(20, (index) =&amp;gt; 'Section ${index + 1}');
  int _activeSection = 0;

  @override
  void initState() {
    super.initState();
    _positionsNotifier.itemPositions.addListener(() {
      final positions = _positionsNotifier.itemPositions.value;
      if (positions.isNotEmpty) {
        final mostVisible = positions.reduce((a, b) =&amp;gt; (a.itemLeadingEdge.abs() + a.itemTrailingEdge.abs()) / 2 &amp;lt; (b.itemLeadingEdge.abs() + b.itemTrailingEdge.abs()) / 2 ? a : b);
        if (_activeSection != mostVisible.index) setState(() =&amp;gt; _activeSection = mostVisible.index);
      }
    });
  }

  @override
  void dispose() {
    _positionsNotifier.itemPositions.removeListener(() {});
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Scroll Spy Navigation')),
      body: Row(
        children: [
          Container(
            width: 200,
            color: Colors.grey[100],
            child: ListView.builder(
              itemCount: _sections.length,
              itemBuilder: (context, index) {
                final isActive = index == _activeSection;
                return ListTile(
                  title: Text(_sections[index], style: TextStyle(fontWeight: isActive ? FontWeight.bold : FontWeight.normal, color: isActive ? Colors.blue : Colors.black87)),
                  selected: isActive,
                  onTap: () =&amp;gt; _scrollController.scrollTo(index: index, duration: const Duration(milliseconds: 300), alignment: 0.0),
                );
              },
            ),
          ),
          Expanded(
            child: ScrollablePositionedList.builder(
              itemCount: _sections.length,
              itemScrollController: _scrollController,
              itemPositionsNotifier: _positionsNotifier,
              itemBuilder: (context, index) =&amp;gt; Container(height: 300, padding: const EdgeInsets.all(24), child: Text(_sections[index], style: Theme.of(context).textTheme.headlineMedium)),
            ),
          ),
        ],
      ),
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;common-pitfalls-and-solutions&quot;&gt;Common Pitfalls and Solutions&lt;/h3&gt;
&lt;h4 id=&quot;pitfall-1-controller-not-attached&quot;&gt;Pitfall 1: Controller Not Attached&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: Calling &lt;code&gt;scrollTo&lt;/code&gt; before the controller is attached.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void _safeScrollTo(int index) {
  if (_itemScrollController.isAttached) {
    _itemScrollController.scrollTo(index: index);
  } else {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_itemScrollController.isAttached) _itemScrollController.scrollTo(index: index);
    });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;pitfall-2-customscrollview-incompatibility&quot;&gt;Pitfall 2: CustomScrollView Incompatibility&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: Doesn't work well inside &lt;code&gt;CustomScrollView&lt;/code&gt; with other slivers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;: Use as standalone or consider &lt;code&gt;super_sliver_list&lt;/code&gt; for complex sliver scenarios. See &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/google/flutter.widgets/issues/32&quot;&gt;GitHub Issue #32&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;pitfall-3-alignment-confusion&quot;&gt;Pitfall 3: Alignment Confusion&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;: The &lt;code&gt;alignment&lt;/code&gt; parameter specifies where the target item positions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0.0&lt;/code&gt; = Top of viewport&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0.5&lt;/code&gt; = Center of viewport&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1.0&lt;/code&gt; = Bottom of viewport&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;comparison-when-to-use-which-widget&quot;&gt;Comparison: When to Use Which Widget&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;ListView &lt;/th&gt;&lt;th&gt;scrollable_positioned_list &lt;/th&gt;&lt;th&gt;CustomScrollView&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Basic scrolling &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;scrollToIndex() &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Visibility detection &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Variable height items &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Custom sliver integration &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;scrollable_positioned_list&lt;/code&gt; for:&lt;/strong&gt; scroll-to-index, visibility tracking, scroll-offset animations, reading progress, scroll spy navigation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use standard &lt;code&gt;ListView&lt;/code&gt; for:&lt;/strong&gt; basic scrolling, maximum performance, CustomScrollView integration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-example-music-player-with-queue&quot;&gt;Real-World Example: Music Player with Queue&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class MusicPlayerQueue extends StatefulWidget {
  const MusicPlayerQueue({Key? key}) : super(key: key);
  @override
  _MusicPlayerQueueState createState() =&amp;gt; _MusicPlayerQueueState();
}

class _MusicPlayerQueueState extends State&amp;lt;MusicPlayerQueue&amp;gt; {
  final ItemScrollController _scrollController = ItemScrollController();
  final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
  final List&amp;lt;Song&amp;gt; _playlist = List.generate(50, (index) =&amp;gt; Song(title: 'Track ${index + 1}', artist: 'Artist ${(index % 10) + 1}', duration: Duration(minutes: 3, seconds: (index * 7) % 60)));
  int _currentTrackIndex = 0;
  bool _isPlaying = false;

  void _playTrack(int index) {
    setState(() =&amp;gt; _currentTrackIndex = _currentTrackIndex = index);
    _scrollController.scrollTo(index: index, alignment: 0.5, duration: const Duration(milliseconds: 400), curve: Curves.easeInOut);
  }

  void _nextTrack() =&amp;gt; _playTrack((_currentTrackIndex + 1) % _playlist.length);
  void _previousTrack() =&amp;gt; _playTrack((_currentTrackIndex - 1 + _playlist.length) % _playlist.length);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Music Player'), actions: [
        IconButton(icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), onPressed: () =&amp;gt; setState(() =&amp;gt; _isPlaying = !_isPlaying))
      ]),
      body: Column(
        children: [
          Container(
            padding: const EdgeInsets.all(16),
            color: Colors.blue.shade50,
            child: Row(children: [
              const Icon(Icons.music_note, size: 48),
              const SizedBox(width: 16),
              Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
                Text(_playlist[_currentTrackIndex].title, style: const TextStyle(fontWeight: FontWeight.bold)),
                Text(_playlist[_currentTrackIndex].artist),
                Text(_formatDuration(_playlist[_currentTrackIndex].duration))
              ])),
              IconButton(icon: const Icon(Icons.skip_previous), onPressed: _previousTrack),
              IconButton(icon: const Icon(Icons.skip_next), onPressed: _nextTrack)
            ]),
          ),
          Expanded(
            child: ScrollablePositionedList.builder(
              itemCount: _playlist.length,
              itemScrollController: _scrollController,
              itemPositionsNotifier: _positionsNotifier,
              itemBuilder: (context, index) {
                final song = _playlist[index];
                final isPlaying = index == _currentTrackIndex;
                return ListTile(
                  leading: CircleAvatar(backgroundColor: isPlaying ? Colors.blue : Colors.grey[300], child: Icon(isPlaying ? (_isPlaying ? Icons.pause : Icons.play_arrow) : Icons.music_note, color: isPlaying ? Colors.white : Colors.black54)),
                  title: Text(song.title, style: TextStyle(fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal, color: isPlaying ? Colors.blue : null)),
                  subtitle: Text(song.artist),
                  trailing: Text(_formatDuration(song.duration)),
                  onTap: () =&amp;gt; _playTrack(index),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  String _formatDuration(Duration d) =&amp;gt; '${d.inMinutes}:${d.inSeconds.toString().padLeft(2, '0')}';
}

class Song {
  final String title, artist;
  final Duration duration;
  Song({required this.title, required this.artist, required this.duration});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-takeaways-1&quot;&gt;Key Takeaways&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;scrollable_positioned_list&lt;/code&gt; package is a hidden gem every Flutter developer should know:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Solves Real Problems&lt;/strong&gt; – Addresses &lt;code&gt;ListView&lt;/code&gt; limitations without complexity&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean API&lt;/strong&gt; – Familiar &lt;code&gt;ListView.builder&lt;/code&gt; pattern with powerful features&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google-Maintained&lt;/strong&gt; – Part of &lt;code&gt;google/flutter.widgets&lt;/code&gt; repository&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Production-Ready&lt;/strong&gt; – Used in apps like API Dash&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feature-Rich&lt;/strong&gt; – &lt;code&gt;scrollToIndex()&lt;/code&gt;, visibility tracking, offset monitoring&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Perfect for:&lt;/strong&gt; playlist apps, documentation readers, chat apps, table of contents, any precise scroll control scenario.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;scrollable_positioned_list&lt;/code&gt; package fills a crucial gap in Flutter's ecosystem by providing &lt;code&gt;scrollToIndex()&lt;/code&gt;, visibility tracking, and scroll offset monitoring – all while maintaining the familiar &lt;code&gt;ListView.builder&lt;/code&gt; API. Try it in your next project!&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11390/the-hidden-gem-of-flutter-lists-unlocking-scrollablepositionedlists-superpowers</guid>
<pubDate>Tue, 10 Feb 2026 19:56:35 +0000</pubDate>
</item>
<item>
<title>Local MongoDB like database in flutter</title>
<link>https://coderlegion.com/2868/local-mongodb-like-database-in-flutter</link>
<description>&lt;h3 id=&quot;flutterdb&quot;&gt;FlutterDB&lt;/h3&gt;
&lt;p&gt;FlutterDB is a lightweight, SQLite-based document database for Flutter applications. It provides a MongoDB-like API for managing collections and documents while leveraging the performance benefits of SQLite.&lt;/p&gt;
&lt;h4 id=&quot;why-do-i-build-this-&quot;&gt;Why do I build this?&lt;/h4&gt;
&lt;p&gt;So I am using MongoDB in my NodeJS application. Now I am building a chat application in Flutter, and for rendering performance, I thought I should store the messages locally and render a sliding window on it. Still, I have no luck finding a good local database that is flexible enough and also fast enough. And so I thought, why not build a MongoDB-like database in Flutter, which gets the job done. It's not a full-fledged MongoDB running locally, but it is similar to it, and most of the common tasks are going very well, as I tested.&lt;/p&gt;
&lt;h4 id=&quot;features&quot;&gt;Features&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Document-oriented database with collections and documents&lt;/li&gt;
&lt;li&gt;MongoDB-style query syntax and operations&lt;/li&gt;
&lt;li&gt;Built on top of SQLite for performance and reliability&lt;/li&gt;
&lt;li&gt;Unique ID generation for documents&lt;/li&gt;
&lt;li&gt;Support for complex queries, updates, and aggregations&lt;/li&gt;
&lt;li&gt;Batch operations for better performance&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;installation&quot;&gt;Installation&lt;/h4&gt;
&lt;p&gt;Add the following dependencies to your &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  flutterdb: ^0.0.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h4&gt;
&lt;p&gt;Import the package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:flutterdb/flutterdb.dart';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Initialize the database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final db = FlutterDB();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;basic-usage&quot;&gt;Basic Usage&lt;/h4&gt;
&lt;h5 id=&quot;working-with-collections&quot;&gt;Working with Collections&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Create or get a collection
final users = await db.collection('users');

// List all collections
final collections = await db.listCollections();

// Drop a collection
await db.dropCollection('users');
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;crud-operations&quot;&gt;CRUD Operations&lt;/h4&gt;
&lt;h5 id=&quot;create&quot;&gt;Create&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Insert a single document
final userId = await users.insert({
  'name': 'John Doe',
  'email': '*Emails are not allowed*',
  'age': 30
});

// Insert multiple documents
final ids = await users.insertMany([
  {
    'name': 'Jane Smith',
    'email': '*Emails are not allowed*',
    'age': 25
  },
  {
    'name': 'Bob Johnson',
    'email': '*Emails are not allowed*',
    'age': 40
  }
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;read&quot;&gt;Read&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Find all documents in a collection
final allUsers = await users.find({});

// Find documents with a simple query
final adults = await users.find({'age': {'$gte': 18}});

// Find a document by ID
final user = await users.findById(userId);

// Count documents
final userCount = await users.count({'age': {'$gt': 30}});
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;update&quot;&gt;Update&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Update a document by ID
await users.updateById(userId, {'status': 'active'});

// Update multiple documents
final updatedCount = await users.updateMany(
  {'age': {'$lt': 30}},
  {'status': 'young'}
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;delete&quot;&gt;Delete&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Delete a document by ID
await users.deleteById(userId);

// Delete multiple documents
final deletedCount = await users.deleteMany({'status': 'inactive'});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;advanced-queries&quot;&gt;Advanced Queries&lt;/h3&gt;
&lt;h4 id=&quot;comparison-operators&quot;&gt;Comparison Operators&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Greater than
await users.find({'age': {'$gt': 30}});

// Less than or equal
await users.find({'age': {'$lte': 25}});

// Not equal
await users.find({'status': {'$ne': 'inactive'}});

// In array
await users.find({'role': {'$in': ['admin', 'moderator']}});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;logical-operators&quot;&gt;Logical Operators&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// AND
await users.find({
  '$and': [
    {'age': {'$gte': 18}},
    {'status': 'active'}
  ]
});

// OR
await users.find({
  '$or': [
    {'role': 'admin'},
    {'permissions': {'$in': ['write', 'delete']}}
  ]
});

// NOR
await users.find({
  '$nor': [
    {'status': 'banned'},
    {'role': 'guest'}
  ]
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;text-search&quot;&gt;Text Search&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Regex search
await users.find({'name': {'$regex': '^Jo'}});

// Simple text search
await users.find({'bio': {'$like': 'flutter developer'}});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;aggregation&quot;&gt;Aggregation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final results = await users.aggregate([
  {'$match': {'status': 'active'}},
  {'$sort': {'age': -1}},
  {'$skip': 10},
  {'$limit': 20},
  {'$project': {
          'name': 1,
          'age': 1,
          'city': 1,
          '_id': 0,
               }
  },
  {'$group': {
          '_id': '\$city',
          'count': {'\$sum': 1}
        }
  },
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;best-practices&quot;&gt;Best Practices&lt;/h3&gt;
&lt;h4 id=&quot;performance-optimization&quot;&gt;Performance Optimization&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use batch operations&lt;/strong&gt; for multiple insertions or updates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;await users.insertMany(manyUsers); // Better than inserting one by one
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep document size reasonable&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Large documents can slow down performance&lt;/li&gt;
&lt;li&gt;Consider storing large binary data (images, files) separately&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create indexes&lt;/strong&gt; for frequently queried fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Currently, FlutterDB automatically indexes collection names&lt;/li&gt;
&lt;li&gt;Future versions may support custom indexes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use appropriate queries&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Finding by ID is faster than complex queries&lt;/li&gt;
&lt;li&gt;Limit results when possible using &lt;code&gt;$limit&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;data-structure&quot;&gt;Data Structure&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use consistent schemas&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;While FlutterDB is schemaless, consistent document structures improve code maintainability&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Choose good document IDs&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Let FlutterDB generate IDs unless you have specific requirements&lt;/li&gt;
&lt;li&gt;Custom IDs should be unique and consistent&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Handle relationships thoughtfully&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For one-to-many relationships, consider embedding related data or using references&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;error-handling&quot;&gt;Error Handling&lt;/h4&gt;
&lt;p&gt;Always implement error handling:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;try {
  await users.insert({'name': 'John'});
} catch (e) {
  print('Error inserting document: $e');
  // Handle error appropriately
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;limitations&quot;&gt;Limitations&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not a full MongoDB replacement&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Limited subset of MongoDB query operators&lt;/li&gt;
&lt;li&gt;Some complex aggregation operations not supported&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance with large datasets&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Best suited for mobile apps with moderate data size&lt;/li&gt;
&lt;li&gt;Consider pagination or limiting queries for large collections&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No built-in encryption&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data is stored in plain text&lt;/li&gt;
&lt;li&gt;Consider additional encryption for sensitive data&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Limited indexing options&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Custom indexes not yet supported&lt;/li&gt;
&lt;li&gt;Consider query performance for large collections&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No network synchronization&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Local database only&lt;/li&gt;
&lt;li&gt;Implement your own sync solution if needed&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;complete-example&quot;&gt;Complete Example&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:flutter/material.dart';
import 'package:flutterdb/flutterdb.dart';

class UserRepository {
  final FlutterDB _db = FlutterDB();
  late Future&amp;lt;Collection&amp;gt; _users;

  UserRepository() {
    _users = _db.collection('users');
  }

  Future&amp;lt;String&amp;gt; addUser(String name, String email, int age) async {
    final collection = await _users;
    return await collection.insert({
      'name': name,
      'email': email,
      'age': age,
      'createdAt': DateTime.now().toIso8601String(),
    });
  }

  Future&amp;lt;List&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt;&amp;gt; getAdultUsers() async {
    final collection = await _users;
    return await collection.find({
      'age': {'$gte': 18},
    });
  }

  Future&amp;lt;void&amp;gt; updateUserStatus(String id, String status) async {
    final collection = await _users;
    await collection.updateById(id, {
      'status': status,
      'updatedAt': DateTime.now().toIso8601String(),
    });
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final userRepo = UserRepository();
  
  // Add a user
  final userId = await userRepo.addUser('Alice', '*Emails are not allowed*', 28);
  print('Added user with ID: $userId');
  
  // Update user status
  await userRepo.updateUserStatus(userId, 'premium');
  
  // Get all adult users
  final adults = await userRepo.getAdultUsers();
  for (var user in adults) {
    print('User: ${user['name']}, Age: ${user['age']}, Status: ${user['status']}');
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;future-enhancements&quot;&gt;Future Enhancements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Custom indexing support&lt;/li&gt;
&lt;li&gt;More aggregation pipeline operators&lt;/li&gt;
&lt;li&gt;Full-text search capabilities&lt;/li&gt;
&lt;li&gt;Data encryption options&lt;/li&gt;
&lt;li&gt;Schema validation&lt;/li&gt;
&lt;li&gt;Observable queries&lt;/li&gt;
&lt;li&gt;Migration support&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;contributing&quot;&gt;Contributing&lt;/h3&gt;
&lt;p&gt;Contributions are welcome! Please feel free to submit a Pull Request.&lt;/p&gt;
&lt;h3 id=&quot;license&quot;&gt;License&lt;/h3&gt;
&lt;p&gt;This project is licensed under the GNU AGPLv3 License - see the LICENSE file for details.&lt;/p&gt;
&lt;p&gt;If you are searching for this keywords &lt;br&gt;
&quot;offline-first Flutter apps&quot;&lt;br&gt;
&quot;local storage in Flutter&quot;&lt;br&gt;
&quot;best local database Flutter&quot;&lt;/p&gt;
&lt;p&gt;your search ends here &lt;br&gt;
I'd love your thoughts. Try &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/flutterdb&quot;&gt;FlutterDB&lt;/a&gt; in your next project and let me know how it goes! You can ⭐️ the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/KTBsomen/flutterdb&quot;&gt;GitHub repo&lt;/a&gt; or open an issue if you hit any snags. &lt;/p&gt;
</description>
<category>Open Source</category>
<guid isPermaLink="true">https://coderlegion.com/2868/local-mongodb-like-database-in-flutter</guid>
<pubDate>Mon, 05 May 2025 17:07:35 +0000</pubDate>
</item>
</channel>
</rss>