<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged flutter</title>
<link>https://coderlegion.com/tag/flutter</link>
<description>Powered by Question2Answer</description>
<item>
<title>Flutter’s UI Architecture Explained for Beginners: Widgets, Elements, and Render Objects</title>
<link>https://coderlegion.com/30644/flutters-ui-architecture-explained-for-beginners-widgets-elements-and-render-objects</link>
<description>&lt;h3 id=&quot;flutter-basics&quot;&gt;Flutter Basics&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#flutter-basics-overview&quot;&gt;Flutter Basics Overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#prerequisites&quot;&gt; Prerequisites&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-is-flutter&quot;&gt;What is Flutter?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#dart&quot;&gt;Dart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#declarative-ui-reactive-programming&quot;&gt;Declarative UI / Reactive Programming&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#multiplatform-development&quot;&gt;Multiplatform Development&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-exactly-is-state&quot;&gt;What exactly is State?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#the-widget-tree-in-flutter&quot;&gt;The Widget Tree in Flutter&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#key-components-of-the-widget-tree&quot;&gt;Key Components of the Widget Tree&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#main-function&quot;&gt;Main Function&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#cupertino-vs-material-widgets&quot;&gt;Cupertino vs Material Widgets&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#flutter-rendering-and-ui-architecture&quot;&gt;Flutter Rendering and UI Architecture&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#difference-between-stateless--stateful-widgets&quot;&gt;Difference between Stateless &amp;amp; Stateful widgets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#stateful-widget-lifecycle-initstate-dispose&quot;&gt;Stateful Widget Lifecycle&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#how-setstate-triggers-ui-rebuilds&quot;&gt;How setState() triggers UI rebuilds&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#text-changer-app-example&quot;&gt;Text Changer App Example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#summary&quot;&gt;Summary&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;flutter-basics-overview&quot;&gt;Flutter Basics Overview&lt;/h3&gt;
&lt;h3 id=&quot;prerequisites-&quot;&gt;Prerequisites:&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Basic understanding of dart programming language &lt;/li&gt;
&lt;li&gt;Flutter installed on your system &lt;/li&gt;
&lt;li&gt;Android studio and Xcode(for Mac)&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;what-is-flutter-&quot;&gt;What is Flutter?&lt;/h4&gt;
&lt;p&gt;Flutter is a &lt;a rel=&quot;nofollow&quot; href=&quot;#declarative-ui-reactive-programming&quot;&gt;declarative&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;#declarative-ui-reactive-programming&quot;&gt;reactive&lt;/a&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://opensource.com/resources/what-open-source&quot;&gt;open-source&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;#multiplatform-development&quot;&gt;multiplatform&lt;/a&gt; UI toolkit developed by Google. It allows developers to build natively compiled applications for mobile, web, and desktop from a single codebase. Flutter uses the &lt;a rel=&quot;nofollow&quot; href=&quot;#dart&quot;&gt;Dart&lt;/a&gt; programming language and provides a rich set of pre-designed widgets to create beautiful and responsive user interfaces.&lt;/p&gt;
&lt;p&gt;Flutter developers often hear terms like everything in Flutter is a widget, and this is because Flutter's UI is built using a hierarchy of widgets called the widget tree. Widgets are the building blocks of a Flutter app's user interface, and they can be combined to create complex UIs.&lt;/p&gt;
&lt;h4 id=&quot;dart&quot;&gt;Dart&lt;/h4&gt;
&lt;p&gt;Dart is an &lt;a rel=&quot;nofollow&quot; href=&quot;https://opensource.com/resources/what-open-source&quot;&gt;open-source&lt;/a&gt; client-optimized programming language developed by Google that compiles to the native machine code of the target platform without runtime overhead leading to fast startup times and predictable performance. It is designed to be easy to learn and use, with a syntax that is similar to other popular programming languages like Java, JavaScript, and C#. Dart is the primary language used for developing Flutter applications.&lt;/p&gt;
&lt;h4 id=&quot;declarative-ui-reactive-programming&quot;&gt;Declarative UI / Reactive Programming&lt;/h4&gt;
&lt;p&gt;Flutter uses a declarative approach: you simply describe what the UI should look like for a given state (like color: blue). The framework then uses reactive programming to automatically react to state changes and rebuild the UI efficiently.&lt;/p&gt;
&lt;h4 id=&quot;multiplatform-development&quot;&gt;Multiplatform Development&lt;/h4&gt;
&lt;p&gt;Multiplatform development refers to the ability to create applications that can run on multiple platforms using a single codebase. Flutter enables developers to build applications for iOS, Android, web, and desktop platforms using the same codebase, which significantly reduces development time and effort.&lt;/p&gt;
&lt;h4 id=&quot;what-exactly-is-state-&quot;&gt;What exactly is State?&lt;/h4&gt;
&lt;p&gt;  Now you may wonder what state is. In simple terms, state refers to the data or information that determines the behavior and appearance of a widget at a given time. For example, the state of a button widget may include whether it is enabled or disabled, its color, and its text label.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nz3p5v0ep9458vnyjqp0.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;In Flutter, state can be managed in various ways, including using built-in stateful widgets, provider packages, or other state management solutions. The choice of state management technique depends on the complexity of the application and the specific requirements of the project. &lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-widget-tree-in-flutter&quot;&gt;The Widget Tree in Flutter&lt;/h3&gt;
&lt;p&gt;Now, as the interface of an application is built using widgets, those widgets are combined to create a widget tree. The widget tree is a hierarchical structure that represents the relationship between different widgets in the application. Each widget in the tree can have its own state, which can be updated and managed independently.&lt;/p&gt;
&lt;p&gt;When the state of a widget changes, Flutter automatically rebuilds the affected parts of the widget tree to reflect the new state. This is done using a process called &lt;code&gt;rebuilding,&lt;/code&gt; which involves creating a new instance of the widget with the updated state and replacing the old instance in the widget tree.&lt;/p&gt;
&lt;p&gt;This process is efficient and allows for smooth and responsive user interfaces, as only the affected parts of the widget tree are rebuilt, rather than the entire tree which facilitates the hot reload feature in Flutter.&lt;/p&gt;
&lt;h4 id=&quot;key-components-of-the-widget-tree&quot;&gt;Key Components of the Widget Tree&lt;/h4&gt;
&lt;p&gt;The widget tree is structured in a hierarchical manner, with each widget having a parent-child relationship with other widgets. The root of the tree is typically the &lt;code&gt;MaterialApp&lt;/code&gt; or &lt;code&gt;CupertinoApp&lt;/code&gt; widget, which serves as the entry point for the application. From there, other widgets are added as children of the root widget, forming a tree-like structure.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uj2nzy2x2pnksub82o40.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h5 id=&quot;main-function&quot;&gt;Main Function&lt;/h5&gt;
&lt;p&gt;The &lt;code&gt;main&lt;/code&gt; function is the entry point of a Flutter application. It is where the execution of the app begins. In the &lt;code&gt;main&lt;/code&gt; function, we typically call the &lt;code&gt;runApp&lt;/code&gt; function, which takes a widget as an argument and inflates it to fill the screen.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vk1poimqqeduv3czx2cw.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h5 id=&quot;cupertino-vs-material-widgets&quot;&gt;Cupertino vs Material Widgets&lt;/h5&gt;
&lt;p&gt;  Flutter provides two sets of pre-designed widgets: Material widgets and Cupertino widgets. Material widgets are designed to follow the Material Design guidelines developed by Google, while Cupertino widgets are designed to follow the iOS design guidelines developed by Apple. Depending on the target platform, developers can choose to use either set of widgets or a combination of both to create a consistent user experience across different platforms.&lt;/p&gt;
&lt;h4 id=&quot;flutter-rendering-and-ui-architecture&quot;&gt;Flutter Rendering and UI Architecture&lt;/h4&gt;
&lt;p&gt;  Flutter initially renders the UI using skia, a 2D graphics rendering engine. Skia is responsible for drawing the UI elements on the screen, and it provides a high-performance rendering pipeline that allows for smooth animations and transitions. But with the release of Flutter 3.0, Flutter introduced a new rendering engine called Impeller, which is designed to provide even better performance and visual fidelity. Impeller is a next-generation rendering engine that leverages modern graphics APIs like Metal on iOS and Vulkan on Android to deliver high-quality graphics and smooth animations.&lt;/p&gt;
&lt;p&gt;Flutter's rendering and UI architecture is primarily built upon three main trees:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Widget Tree&lt;/strong&gt;: The widget tree is the hierarchical structure of widgets that make up the user interface of a Flutter application. Each widget in the tree represents a specific UI element, such as a button, text field, or image. The widget tree is responsible for defining the layout and appearance of the UI.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Element Tree&lt;/strong&gt;: The element tree is a lower-level representation of the widget tree. It consists of &lt;code&gt;Element&lt;/code&gt; objects, which are created for each widget in the widget tree (Like a copy of the widget). The element tree is responsible for managing the lifecycle of widgets, including their creation, updating, and destruction. It also handles the communication between widgets and their associated state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Render Tree (or RenderObject Tree):&lt;/strong&gt;: The render tree is the final representation of the UI that is used for painting on the screen. It consists of &lt;code&gt;RenderObject&lt;/code&gt; instances, which are responsible for the actual layout and painting of the UI elements. The render tree is built from the element tree and is optimized for performance and efficiency during the rendering process.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In summary,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The Widget is the structural description of the UI (Blueprint). it is the &lt;code&gt;immutable&lt;/code&gt; description of what the UI should look like and provides configuration and hierarchy.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Element is the &lt;code&gt;mutable&lt;/code&gt; brain that manages the lifecycle of the widget and holds the state. It acts as a bridge between the widget and the render object. Determines what needs to be updated when the widget's state changes.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Render Object is the Builder/Artist that is responsible for the actual layout and painting(drawing pixels) of the UI on the screen. It takes care of measuring, positioning, and drawing the visual representation of the widget.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The interaction between these three trees is crucial for the efficient rendering of the UI in Flutter. When a widget's state changes, Flutter updates the widget tree, which in turn updates the element tree and the render tree. This process ensures that only the affected parts of the UI are redrawn, leading to smooth and responsive user interfaces.&lt;/p&gt;
&lt;h3 id=&quot;difference-between-stateless-amp-stateful-widgets&quot;&gt;Difference between Stateless &amp;amp; Stateful widgets&lt;/h3&gt;
&lt;p&gt;The widget tree can be broadly categorized into two types of widgets based on their ability to manage state:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stateless Widgets&lt;/strong&gt;: These are widgets that do not maintain any state. Once built, their appearance and behavior are fixed and cannot change. Stateless widgets are typically used for static content that does not require user interaction or dynamic updates.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stateful Widgets&lt;/strong&gt;: These are widgets that can maintain state over time. They can rebuild themselves when their state changes, allowing for dynamic and interactive user interfaces. Stateful widgets are used for content that needs to change in response to user input or other events.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/18hdofqfc9spchai5wkz.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Other kinds of widgets include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Inherited Widgets&lt;/strong&gt;: These widgets allow data to be passed down the widget tree efficiently. They are used for sharing data between widgets without the need for explicit passing through constructors. examples include &lt;code&gt;Theme&lt;/code&gt; and &lt;code&gt;MediaQuery&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;stateful-widget-lifecycle-initstate-dispose-&quot;&gt;Stateful Widget Lifecycle (initState, dispose)&lt;/h3&gt;
&lt;p&gt;Stateful widgets have a lifecycle that consists of several stages, each represented by specific methods that can be overridden to perform actions at different points in the widget's life. The key lifecycle methods of a stateful widget are:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3c994uf7i40800tbsu2j.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;createState()&lt;/strong&gt;: This method is called when the stateful widget is first created. It returns an instance of the associated &lt;code&gt;State&lt;/code&gt; class, which holds the mutable state for the widget.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/se7x9682wd7q2rjrvbyp.png&quot; alt=&quot;Image description&quot;&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;initState()&lt;/strong&gt;: This method is called after the stateful widget is created and before the widget is built for the first time. It is used to perform any initializations that are required for the state.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v0pfm2t8y9o9xpiz4007.png&quot; alt=&quot;Image description&quot;&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;didChangeDependencies()&lt;/strong&gt;: Called immediately after &lt;code&gt;initState()&lt;/code&gt;, but before the widget is built for the first time. It's also called when an InheritedWidget that this State depends on changes. It is often a good place to initialize data that depends on the BuildContext (like Theme.of(context)), as initState() may be too early.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;build()&lt;/strong&gt;: This method is called whenever the widget needs to be rebuilt, either due to a change in state or when the parent widget rebuilds. It returns the widget tree that represents the UI of the stateful widget.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rb8cqk17y11lpvfv3ymt.png&quot; alt=&quot;Image description&quot;&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;setState()&lt;/strong&gt;: (Triggered by you) This method is used to notify the framework that the state of the widget has changed and that it needs to be rebuilt.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zcca5didfabts1du60qa.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
The image above illustrates how calling &lt;code&gt;setState()&lt;/code&gt; leads to the invocation of the &lt;code&gt;build()&lt;/code&gt; method, when the user hits enter after inputing a city name in the TextField. it fetches new weather data and updates the UI accordingly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;didUpdateWidget()&lt;/strong&gt;: This method is called whenever the parent widget rebuilds and passes new configuration data down to the child stateful widget. It allows the state to respond to changes in the widget's properties by passing the updated stateful widget as a parameter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;deactivate()&lt;/strong&gt;: This method is called when the State object is removed from the Element tree, but before it is disposed. This might happen temporarily if the widget is moved to a different part of the tree.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dispose()&lt;/strong&gt;: This is called after deactivate() when the State object is permanently removed from the tree. This is the last method called, and it's essential to use it for cleanup (e.g., canceling timers, unsubscribing from streams, disposing of controllers) to prevent memory leaks.&lt;br&gt;
&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5k51gj19t3vns6sak6q8.png&quot; alt=&quot;Image description&quot;&gt;&lt;br&gt;
The image above shows the &lt;code&gt;dispose()&lt;/code&gt; method being called when the &lt;code&gt;StatefulWidget&lt;/code&gt; which is a TextFormField controller is removed from the widget tree after a user submits a form. This ensures that resources are properly released and prevents memory leaks.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;how-setstate-triggers-ui-rebuilds&quot;&gt;How setState() triggers UI rebuilds&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;setState()&lt;/code&gt; method is a crucial part of the stateful widget lifecycle in Flutter. When you call &lt;code&gt;setState()&lt;/code&gt;, you are indicating to the Flutter framework that the internal state of the widget has changed and that the widget needs to be rebuilt to reflect those changes in the UI.&lt;br&gt;
When &lt;code&gt;setState()&lt;/code&gt; is called, the following sequence of events occurs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The callback function passed to &lt;code&gt;setState()&lt;/code&gt; is executed, allowing you to update the state variables of the widget.&lt;/li&gt;
&lt;li&gt;After the state has been updated, Flutter marks the widget as needing to be rebuilt.&lt;/li&gt;
&lt;li&gt;The framework schedules a rebuild of the widget tree, starting from the stateful widget that called &lt;code&gt;setState()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;During the rebuild process, the &lt;code&gt;build()&lt;/code&gt; method of the stateful widget is called, which constructs a new widget tree based on the updated state.&lt;/li&gt;
&lt;li&gt;The framework compares the new widget tree with the previous one and determines which parts of the UI need to be updated.&lt;/li&gt;
&lt;li&gt;Finally, the framework updates the UI by redrawing only the affected parts of the widget tree, resulting in a smooth and efficient update to the user interface.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;text-changer-app-example&quot;&gt;Text Changer App Example&lt;/h3&gt;
&lt;p&gt;Click &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ideateGudy/Flutter_Mobile_Development_Series/blob/main/day%201_flutter%20basics/text_changer&quot;&gt;Here to view the Text Changer App Explanation.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xesr5996330re6zbya69.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7eeuyc0rgfhj8iaxpqqo.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;In summary, the three trees in Flutter's rendering architecture work together to create a responsive and efficient UI. The widget tree defines the structure and appearance of the UI, the element tree manages the lifecycle and state of the widgets, and the render tree is responsible for the actual painting of the UI on the screen. Understanding these trees and their interactions is key to building performant Flutter applications.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30644/flutters-ui-architecture-explained-for-beginners-widgets-elements-and-render-objects</guid>
<pubDate>Sat, 10 Oct 2026 20:38:02 +0000</pubDate>
</item>
<item>
<title>Bento Scan AI Document Scanner</title>
<link>https://coderlegion.com/29328/bento-scan-ai-document-scanner</link>
<description>&lt;h3 id=&quot;a-white-label-scanner-that-works-offline-first&quot;&gt;A white-label scanner that works offline first&lt;/h3&gt;
&lt;p&gt;Bento Scan is a production-ready document scanner for Android and iOS built with Flutter. The mobile app handles the capture workflow on-device: it detects page boundaries, triggers automatic capture, corrects perspective, flattens curved pages, applies filters and exports multi-page PDFs.&lt;/p&gt;
&lt;p&gt;Many scanner apps depend on proprietary cloud services for basic processing. Bento Scan keeps the essential scan pipeline offline, then pairs it with an optional self-hosted backend for teams that want synchronization, search and administration under their own control.&lt;/p&gt;
&lt;h3 id=&quot;included-capabilities&quot;&gt;Included capabilities&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Offline document-edge detection and automatic capture&lt;/li&gt;
&lt;li&gt;Perspective correction and AI page flattening&lt;/li&gt;
&lt;li&gt;Multi-page scanning, filters and PDF export&lt;/li&gt;
&lt;li&gt;OCR in more than 100 languages&lt;/li&gt;
&lt;li&gt;Searchable PDFs, folders, tags and full-text search&lt;/li&gt;
&lt;li&gt;Quotas, administration statistics and S3-compatible storage&lt;/li&gt;
&lt;li&gt;Responsive web dashboard&lt;/li&gt;
&lt;li&gt;Editable Flutter, FastAPI and deployment source code&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The package can be rebranded for a commercial scanner, an internal capture tool or a document workflow product. Developers receive the mobile app, backend, web dashboard, deployment configuration and training utilities rather than a closed SDK.&lt;/p&gt;
&lt;p&gt;See device requirements, screenshots and the complete package on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.codester.com/items/71446/bento-scan-ai-document-scanner-flutter-app&quot;&gt;Codester&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/29328/bento-scan-ai-document-scanner</guid>
<pubDate>Thu, 01 Oct 2026 01:33:38 +0000</pubDate>
</item>
<item>
<title> Meet Folio: The Open-Source, Local-First Workspace for Your Notes and Ideas</title>
<link>https://coderlegion.com/27891/meet-folio-the-open-source-local-first-workspace-for-your-notes-and-ideas</link>
<description>&lt;p&gt;If this is your first time encountering my work, I want to introduce you to a project I have been passionately building: &lt;strong&gt;Folio&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Folio is an open-source personal productivity and note-taking workspace. Think of it as a highly flexible workspace—similar in versatility to Notion—built with a fundamental commitment to privacy and data ownership: it is &lt;strong&gt;local-first&lt;/strong&gt;. Your data lives on your device, giving you total ownership and full offline access by default.&lt;/p&gt;
&lt;p&gt;The entire source code is available on &lt;strong&gt;GitHub&lt;/strong&gt;, empowering the community to inspect, contribute, or build upon it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-everything-you-can-do-with-folio&quot;&gt;️ Everything You Can Do with Folio&lt;/h3&gt;
&lt;p&gt;Folio is designed to be a complete, privacy-focused environment for organizing thoughts, managing projects, and writing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; A Modular Block Editor:&lt;/strong&gt; Craft documents using over 31 block types, including WYSIWYG rich text, interactive Kanban boards, editable tables, Mermaid diagrams, LaTeX equations, and an Infinite Canvas for visual brainstorming.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;️ Meeting Notes &amp;amp; Transcription:&lt;/strong&gt; Record audio directly within a page and process offline transcriptions locally using Whisper.cpp, complete with speaker diarization.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; Quill (AI Assistant) &amp;amp; Plan Mode:&lt;/strong&gt; Folio includes an integrated AI assistant named Quill. With &lt;strong&gt;Plan Mode&lt;/strong&gt;, Quill drafts an editable action plan before modifying your workspace, allowing you to review and approve changes first. You can run Quill completely offline using local models (Ollama or LM Studio), bring your own API keys, or connect to Folio Cloud.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; Local MCP Server:&lt;/strong&gt; Expose workspace tools directly to external Model Context Protocol (MCP) clients like Github Copilot or Cursor for advanced local AI workflows.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; Encrypted Vaults &amp;amp; P2P Sync:&lt;/strong&gt; Protect your vaults with a master password, Windows Hello, or Passkeys. Sync encrypted vaults across your local network (LAN) peer-to-peer without touching external servers.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;️ Knowledge Graph &amp;amp; Task Hub:&lt;/strong&gt; Visualize connections between your notes using a force-directed graph view, and track every action item across your workspace in a unified Task Hub.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-0-7-8-milestone-moving-to-a-custom-java-backend&quot;&gt;The 0.7.8 Milestone: Moving to a Custom Java Backend&lt;/h3&gt;
&lt;p&gt;While Folio is 100% functional offline, its optional cloud layer just underwent its biggest architectural shift yet.&lt;/p&gt;
&lt;p&gt;As a backend developer specializing in Java and microservices, keeping Folio Cloud tied to Firebase became too restrictive for scaling. I decided to rebuild the entire cloud infrastructure from scratch: &lt;strong&gt;Folio Cloud has officially left Firebase and now runs on a custom Java 21 &amp;amp; Spring Boot backend&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This rewrite brings faster, reactive synchronization, lower CPU overhead, and total architectural control. In tandem, version 0.7.8 introduces a &lt;strong&gt;new file-tree vault engine&lt;/strong&gt; with granular page history and strict anti-wipe safety guards to keep your notes safe.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;open-source-amp-supporting-the-project&quot;&gt;Open Source &amp;amp; Supporting the Project&lt;/h3&gt;
&lt;p&gt;Folio is, and will always remain, &lt;strong&gt;free and open-source&lt;/strong&gt; for local use.&lt;/p&gt;
&lt;p&gt;To help cover server costs and support continuous full-time development, I offer an optional &lt;strong&gt;Folio Cloud subscription&lt;/strong&gt;. Subscribing unlocks convenience features such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Multi-device encrypted cloud sync.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Hosted AI for Quill.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Encrypted cloud backups &amp;amp; expanded storage.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Public web publishing and real-time collaboration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you love the mission of local-first, open-source software, subscribing to Folio Cloud is the best way to support the roadmap!&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;get-started&quot;&gt;Get Started&lt;/h3&gt;
&lt;p&gt;Folio is available for &lt;strong&gt;Windows, macOS, Linux, Android, iOS, and Web&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Website &amp;amp; Downloads:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://minealexgames.com/en/folio&quot;&gt;minealexgames.com/en/folio&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; Check out the source code, star the project, or contribute on GitHub.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;(Remember to make an external backup of your vaults before upgrading to 0.7.8, and enjoy the new update!)&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27891/meet-folio-the-open-source-local-first-workspace-for-your-notes-and-ideas</guid>
<pubDate>Mon, 21 Sep 2026 06:07:12 +0000</pubDate>
</item>
<item>
<title>Tracking From the Grave: How Apps Follow Your Location After They're Killed</title>
<link>https://coderlegion.com/26458/tracking-from-the-grave-how-apps-follow-your-location-after-theyre-killed</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Before you start: there are three ways to build this&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This blog covers all three, so pick the one that fits you before you follow the code.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The plugin way&lt;/strong&gt; (&lt;code&gt;flutter_background_geolocation&lt;/code&gt;), covered in &quot;Step by step: build this yourself in Flutter&quot;. Fastest to ship and the best battery behaviour. It is a paid plugin from Transistorsoft, free in debug builds, but a release build needs a license key tied to your package id or bundle id.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Dart-only way&lt;/strong&gt; (&lt;code&gt;geolocator&lt;/code&gt; plus your own foreground service), covered in the teardown section. Free, simpler, but weaker at surviving a force quit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The native way&lt;/strong&gt; (your own Kotlin and Swift code over method and event channels), also in the teardown section. Free, most control, most work.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So if you are building your own tracking from scratch, options 2 and 3 need no license key at all. The key is only for option 1.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The one idea to hold in your head&lt;/strong&gt;&lt;br&gt;
A background location app does NOT stay running the whole time. It cannot. Both Android and iOS will kill it. So instead of staying alive, it hands a few smart triggers to the phone's operating system and then lets itself die. The OS keeps those triggers and wakes the app back up at exactly the right second. Accuracy does not come from running 24x7. It comes from being clever about WHEN to wake up and switch on GPS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Read that twice. Once you accept that the app is supposed to die, everything else makes sense. Beginners assume the app is secretly running in a corner forever. It is not. It is mostly dead, and that is the whole trick.&lt;/p&gt;
&lt;h3 id=&quot;why-you-cannot-just-keep-the-app-running&quot;&gt;Why you cannot just keep the app running&lt;/h3&gt;
&lt;p&gt;If any app could run GPS forever in the background, your battery would be gone by lunch and every shady app would be following you around. So phone makers put a hard stop on it. Two things happen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The OS kills background apps.&lt;/strong&gt; When your phone needs memory, or you swipe the app away, or it just decides your app has been idle too long, the operating system shuts your app down. You do not get a vote.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GPS is expensive.&lt;/strong&gt; The GPS chip is one of the hungriest things on the phone. Keep it on continuously and the battery melts.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So the plugin works WITH these rules instead of fighting them. It assumes it will be killed and plans for resurrection. Think of it like leaving a note with a friend saying &quot;wake me when I cross this line.&quot; You go to sleep. The friend (the OS) holds the note and shakes you awake at the right moment.&lt;/p&gt;
&lt;h3 id=&quot;the-heart-of-it-be-lazy-to-be-accurate&quot;&gt;The heart of it: be lazy to be accurate&lt;/h3&gt;
&lt;p&gt;Here is the clever bit that makes both battery life AND accuracy possible at once. It sounds backwards, but stay with me.&lt;/p&gt;
&lt;p&gt;When you are &lt;strong&gt;standing still&lt;/strong&gt;, GPS is turned OFF. The app drops an invisible circle around your current spot (this circle is called a &lt;strong&gt;geofence&lt;/strong&gt;) and goes to sleep. The phone uses almost no battery. The app process can even die. Nobody cares, because nothing is moving.&lt;/p&gt;
&lt;p&gt;The moment you &lt;strong&gt;start moving&lt;/strong&gt; and step outside that circle, the OS notices and wakes the app up. Now the app turns on high-accuracy GPS and records every point while you travel. When you stop again, it drops a fresh circle, switches GPS off, and goes back to sleep.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/19bf859f-bf9e-447b-a706-f4d637ceab78.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;It is accurate BECAUSE it is lazy. It does not waste GPS while you sit at your desk, which keeps the battery happy, which means the OS does not feel the urge to kill it, which means it is alive and precise the moment you actually move. Battery saving and accuracy are the same feature here, not a trade off.&lt;/p&gt;
&lt;h3 id=&quot;now-the-part-everyone-asks-about-what-happens-when-the-app-dies&quot;&gt;Now the part everyone asks about: what happens when the app dies&lt;/h3&gt;
&lt;p&gt;This is where Android and iOS go in completely different directions. One at a time.&lt;/p&gt;
&lt;h4 id=&quot;android-a-permanent-worker-plus-notes-held-by-the-system&quot;&gt;Android: a permanent worker plus notes held by the system&lt;/h4&gt;
&lt;p&gt;While your app is alive in the background, Android keeps it tracking using a &lt;strong&gt;foreground service&lt;/strong&gt;. You have seen it: that notification saying &quot;App is using your location&quot; which you cannot swipe away. It is the deal you make with Android. Show the user a notification, and in exchange you get to keep running and reading GPS in the background. No notification, no permission. That is the rule on modern Android.&lt;/p&gt;
&lt;p&gt;This foreground service is set to restart itself if Android kills it for memory reasons. So far so good. But what about when your app process is FULLY dead? This is the magic part.&lt;/p&gt;
&lt;p&gt;The plugin registers two kinds of &quot;notes&quot; with &lt;strong&gt;Google Play Services&lt;/strong&gt;, which is a separate system app that does NOT die when your app dies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Geofence triggers.&lt;/strong&gt; &quot;Hey Play Services, when this phone crosses this circle, wake my app.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Activity Recognition triggers.&lt;/strong&gt; &quot;Hey Play Services, when this phone goes from still to walking or driving, wake my app.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When one of those conditions happens, Play Services reaches into your dead app and cold-starts a tiny piece of it called a &lt;strong&gt;BroadcastReceiver&lt;/strong&gt;. Your app gets a few seconds of life, the engine wakes up, grabs the location, and does its job. Your Flutter and Dart code can be completely shut down and this still works. That is &lt;strong&gt;headless&lt;/strong&gt; mode: the native engine runs without the rest of your app being awake.&lt;/p&gt;
&lt;p&gt;And if the phone reboots? The plugin also registers a &quot;on boot&quot; note, so when the phone turns back on, it re-arms everything automatically.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/b73618f7-cd0d-4236-8bc9-6ae43cfc164d.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;ios-the-system-relaunches-your-app-for-you&quot;&gt;iOS: the system relaunches your app for you&lt;/h4&gt;
&lt;p&gt;iOS is much stricter. You genuinely cannot run forever in the background, end of story. But Apple gives location tracking one special superpower: a couple of services are allowed to &lt;strong&gt;relaunch your app even after it has been force-quit, and even after a reboot&lt;/strong&gt;. Almost nothing else on iOS can do this.&lt;/p&gt;
&lt;p&gt;The two services are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Region Monitoring&lt;/strong&gt;, which is iOS's version of geofences. Cross a monitored circle and iOS relaunches your app in the background.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Significant Location Change (SLC)&lt;/strong&gt;, which fires when you move a meaningful distance, detected cheaply using cell towers and wifi instead of GPS.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When one of these fires, iOS relaunches your app in the background, hands it a flag saying &quot;you woke up because of location,&quot; gives you a short moment to act, then suspends you again. Important detail: plain continuous GPS does NOT survive a force-quit on iOS by itself. So the plugin uses regions and SLC as the wake-up layer, and only switches on precise GPS once it is already awake and senses real movement.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/046bd91a-86c5-4744-aa39-c62c60c1efcd.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is also why, on iOS, the very first point after a kill can feel a little delayed. The app had to be relaunched by an event before it could even start tracking.&lt;/p&gt;
&lt;h3 id=&quot;-terminated-is-not-just-one-thing&quot;&gt;&quot;Terminated&quot; is not just one thing&lt;/h3&gt;
&lt;p&gt;Beginners treat &quot;closed&quot; as a single state. It is actually a ladder, and behaviour changes at each rung.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;State &lt;/th&gt;&lt;th&gt;What it means &lt;/th&gt;&lt;th&gt;Does tracking survive?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Backgrounded, still alive &lt;/td&gt;&lt;td&gt;You pressed home, app is in memory &lt;/td&gt;&lt;td&gt;Yes, easily (foreground service on Android, background mode on iOS)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Killed by the OS for memory &lt;/td&gt;&lt;td&gt;Phone needed RAM and shut your app &lt;/td&gt;&lt;td&gt;Yes (Android restarts it and geofence/motion triggers revive it, iOS relaunches via SLC/regions)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Force-quit by the user &lt;/td&gt;&lt;td&gt;You swiped it out of recents &lt;/td&gt;&lt;td&gt;Android can be harsher and may delay, but Play Services triggers still cold-start a receiver. iOS still relaunches via regions and SLC&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;After reboot &lt;/td&gt;&lt;td&gt;Phone was turned off and on &lt;/td&gt;&lt;td&gt;Yes (Android re-arms with a boot receiver, iOS regions and SLC persist)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;So when someone asks &quot;does it work after the app is closed,&quot; the honest answer is &quot;yes, but the mechanism that revives it depends on exactly how it was closed.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-last-piece-never-lose-a-single-point&quot;&gt;The last piece: never lose a single point&lt;/h3&gt;
&lt;p&gt;Accuracy is not only about capturing locations, it is about not dropping them. What if the phone is in a tunnel, or has no signal, or your server is down for a minute?&lt;/p&gt;
&lt;p&gt;The plugin writes every location into a small local database (&lt;strong&gt;SQLite&lt;/strong&gt;) on the phone FIRST, before doing anything else. A separate part of the system then uploads those saved points to your server. If the upload fails because there is no internet, the points wait in the database and go up later, in the correct order, once the connection is back.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/69231e2a-d2f4-4b00-9f0f-987a08dc7c8a.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Golden rule to copy for any tracking app: record locally first, sync as a totally separate concern. A dead zone should never punch a hole in your track.&lt;/p&gt;
&lt;h3 id=&quot;the-full-picture-in-one-diagram&quot;&gt;The full picture in one diagram&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/bb27fdc5-3e64-4dca-99da-3cbd976396cf.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;That loop is the entire product. Sleep cheaply, wake on a real signal, track precisely while moving, save everything, repeat.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-by-step-build-this-yourself-in-flutter&quot;&gt;Step by step: build this yourself in Flutter&lt;/h2&gt;
&lt;p&gt;This section is a complete walk through using the &lt;code&gt;flutter_background_geolocation&lt;/code&gt; package, since that is the battle-tested way to get all of the above without writing the native code yourself. Follow it top to bottom.&lt;/p&gt;
&lt;h3 id=&quot;step-0-understand-what-you-are-signing-up-for&quot;&gt;Step 0: understand what you are signing up for&lt;/h3&gt;
&lt;p&gt;This package is free to fully test in DEBUG builds. For a RELEASE build on Android and for the App Store on iOS, you need a paid license key tied to your package id or bundle id. So you build and test for free, and only pay when you ship. Plan for that.&lt;/p&gt;
&lt;h3 id=&quot;step-1-add-the-package&quot;&gt;Step 1: add the package&lt;/h3&gt;
&lt;p&gt;In your &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  flutter_background_geolocation: ^4.16.0
  # the package also needs its companion, added automatically in most cases
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run &lt;code&gt;flutter pub get&lt;/code&gt;. Always check pub.dev for the latest version number rather than copying mine.&lt;/p&gt;
&lt;h3 id=&quot;step-2-android-setup&quot;&gt;Step 2: Android setup&lt;/h3&gt;
&lt;h4 id=&quot;2a-permissions-in-androidmanifest-xml&quot;&gt;2a. Permissions in AndroidManifest.xml&lt;/h4&gt;
&lt;p&gt;Inside &lt;code&gt;android/app/src/main/AndroidManifest.xml&lt;/code&gt;, above the &lt;code&gt;&amp;lt;application&amp;gt;&lt;/code&gt; tag, declare what you need. Each line has a reason:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;uses-permission android:name=&quot;android.permission.ACCESS_COARSE_LOCATION&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.ACCESS_FINE_LOCATION&quot; /&amp;gt;
&amp;lt;!-- lets you track while the app is in the background --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.ACCESS_BACKGROUND_LOCATION&quot; /&amp;gt;
&amp;lt;!-- the permanent notification that keeps you alive --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE_LOCATION&quot; /&amp;gt;
&amp;lt;!-- detect still vs walking vs driving --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.ACTIVITY_RECOGNITION&quot; /&amp;gt;
&amp;lt;!-- re-arm tracking after the phone reboots --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.RECEIVE_BOOT_COMPLETED&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.WAKE_LOCK&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2b-license-key-only-needed-for-release-&quot;&gt;2b. License key (only needed for release)&lt;/h4&gt;
&lt;p&gt;Inside the &lt;code&gt;&amp;lt;application&amp;gt;&lt;/code&gt; tag, add your license key as a meta-data line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;meta-data
  android:name=&quot;com.transistorsoft.locationmanager.license&quot;
  android:value=&quot;YOUR_LICENSE_KEY&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2c-gradle-repository&quot;&gt;2c. Gradle repository&lt;/h4&gt;
&lt;p&gt;The package pulls its native engine from Maven. Recent versions wire this up for you, but if a build fails saying it cannot find &lt;code&gt;tslocationmanager&lt;/code&gt;, check the package's install docs for the repository line to add in &lt;code&gt;android/build.gradle&lt;/code&gt; or &lt;code&gt;settings.gradle&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;step-3-ios-setup&quot;&gt;Step 3: iOS setup&lt;/h3&gt;
&lt;h4 id=&quot;3a-info-plist-text&quot;&gt;3a. Info.plist text&lt;/h4&gt;
&lt;p&gt;In &lt;code&gt;ios/Runner/Info.plist&lt;/code&gt;, add the permission descriptions. iOS shows these sentences to the user, so write them in plain language explaining WHY:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;key&amp;gt;NSLocationWhenInUseUsageDescription&amp;lt;/key&amp;gt;
&amp;lt;string&amp;gt;We use your location to record your visits while you use the app.&amp;lt;/string&amp;gt;
&amp;lt;key&amp;gt;NSLocationAlwaysAndWhenInUseUsageDescription&amp;lt;/key&amp;gt;
&amp;lt;string&amp;gt;We record your location in the background so your route is not lost.&amp;lt;/string&amp;gt;
&amp;lt;key&amp;gt;NSMotionUsageDescription&amp;lt;/key&amp;gt;
&amp;lt;string&amp;gt;We use motion data to save battery while tracking.&amp;lt;/string&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3b-background-modes&quot;&gt;3b. Background modes&lt;/h4&gt;
&lt;p&gt;Still in &lt;code&gt;Info.plist&lt;/code&gt;, switch on the background abilities:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;key&amp;gt;UIBackgroundModes&amp;lt;/key&amp;gt;
&amp;lt;array&amp;gt;
  &amp;lt;string&amp;gt;location&amp;lt;/string&amp;gt;
  &amp;lt;string&amp;gt;fetch&amp;lt;/string&amp;gt;
&amp;lt;/array&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also enable these by ticking &quot;Location updates&quot; and &quot;Background fetch&quot; under Signing and Capabilities in Xcode.&lt;/p&gt;
&lt;h3 id=&quot;step-4-ask-for-permission-the-right-way&quot;&gt;Step 4: ask for permission the right way&lt;/h3&gt;
&lt;p&gt;Do not ask for background location on the very first screen. Both stores dislike it and users reject it. Ask for &quot;while in use&quot; first, explain why in your own UI, then request &quot;always.&quot; The plugin requests what it needs when you call &lt;code&gt;ready&lt;/code&gt;, but a gentle explanation screen before that hugely improves acceptance.&lt;/p&gt;
&lt;h3 id=&quot;step-5-configure-and-start-tracking-the-dart-part-&quot;&gt;Step 5: configure and start tracking (the Dart part)&lt;/h3&gt;
&lt;p&gt;Here is a minimal but complete setup. Read the comments, they explain each choice.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg;

Future&amp;lt;void&amp;gt; initTracking() async {
  // 1. Listen for each new location
  bg.BackgroundGeolocation.onLocation((bg.Location location) {
    print('New location: ${location.coords.latitude}, ${location.coords.longitude}');
  });

  // 2. Listen for the still &amp;lt;-&amp;gt; moving switch
  bg.BackgroundGeolocation.onMotionChange((bg.Location location) {
    print('Moving state changed. isMoving = ${location.isMoving}');
  });

  // 3. Configure the engine
  await bg.BackgroundGeolocation.ready(bg.Config(
    desiredAccuracy: bg.Config.DESIRED_ACCURACY_HIGH, // best GPS while moving
    distanceFilter: 10,            // record a point every 10 meters of movement
    stopOnTerminate: false,        // keep tracking after the app is killed
    startOnBoot: true,             // re-arm after the phone reboots
    enableHeadless: true,          // allow waking with no UI alive
    foregroundService: true,       // the persistent notification on Android
    url: 'https://your-server.com/locations', // where to upload points
    autoSync: true,                // upload automatically
    batchSync: false,              // send points one by one (or batch them)
  )).then((bg.State state) {
    if (!state.enabled) {
      // 4. Actually start it
      bg.BackgroundGeolocation.start();
    }
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two settings that make it survive being killed are &lt;code&gt;stopOnTerminate: false&lt;/code&gt; and &lt;code&gt;startOnBoot: true&lt;/code&gt;. The one that makes it work with no UI is &lt;code&gt;enableHeadless: true&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;step-6-handle-the-headless-wake-up-android-&quot;&gt;Step 6: handle the headless wake-up (Android)&lt;/h3&gt;
&lt;p&gt;When the OS wakes your dead app, your normal Dart code is not running. You register a separate tiny function that the engine can call in that headless moment. Put this at the very top level of your &lt;code&gt;main.dart&lt;/code&gt;, outside any class.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// must be a top-level function
@pragma('vm:entry-point')
void headlessTask(bg.HeadlessEvent headlessEvent) async {
  switch (headlessEvent.name) {
    case bg.Event.LOCATION:
      bg.Location location = headlessEvent.event;
      print('Headless location: $location');
      break;
    case bg.Event.MOTIONCHANGE:
      print('Headless motionchange');
      break;
  }
}

void main() {
  runApp(const MyApp());
  // register the headless handler
  bg.BackgroundGeolocation.registerHeadlessTask(headlessTask);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;@pragma('vm:entry-point')&lt;/code&gt; line is not optional. It stops Flutter from throwing this function away during release optimization, because the engine has to find it by name when the app is otherwise dead.&lt;/p&gt;
&lt;h3 id=&quot;step-7-test-it-properly&quot;&gt;Step 7: test it properly&lt;/h3&gt;
&lt;p&gt;Testing with the app open proves nothing. Real tests:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Start tracking, then swipe the app out of recents. Walk around the block. Check your server received points.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reboot the phone without opening the app. Move. Confirm tracking re-armed itself.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sit still for ten minutes and watch the battery. It should barely move. If GPS is stuck on, your config is wrong.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Turn on airplane mode, move around, then turn internet back on. Confirm the queued points upload in order.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;step-8-survive-the-cheap-phone-problem-very-important-in-india-&quot;&gt;Step 8: survive the cheap-phone problem (very important in India)&lt;/h3&gt;
&lt;p&gt;Many budget Android phones (Xiaomi, Oppo, Vivo, Realme and others) run aggressive &quot;battery saver&quot; software that kills background apps no matter what the rules say. This is the number one reason tracking apps fail in the real world here. Defenses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Guide the user to disable battery optimization for your app. The plugin has a helper, &lt;code&gt;bg.DeviceSettings&lt;/code&gt;, that can open the right settings screen.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;For company-owned devices, look into Android &lt;strong&gt;Device Owner&lt;/strong&gt; or a managed enrollment. When your app is the device owner, the OEM battery killers no longer apply, and force-quit and optimization simply stop being a problem. For a controlled set of devices, this is the most reliable answer by far.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Show a clear one-time screen explaining that they must allow background location and turn off battery optimization, or tracking will be unreliable.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;quick-mental-checklist&quot;&gt;Quick mental checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The app is meant to die. Plan for resurrection, do not fight it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sleep with GPS off behind a geofence. Wake on geofence exit or motion.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Android revives via a foreground service plus Play Services geofence and activity triggers, re-armed on boot.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;iOS revives via Region Monitoring and Significant Location Change, which can relaunch even after force-quit.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Save every point to local SQLite first, upload as a separate retrying step.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;stopOnTerminate: false&lt;/code&gt;, &lt;code&gt;startOnBoot: true&lt;/code&gt;, &lt;code&gt;enableHeadless: true&lt;/code&gt; are the three settings that matter most.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On cheap Android phones, defeat the OEM battery killer or your tracking dies silently.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-does-the-foreground-service-actually-show-up-clearing-the-confusion-&quot;&gt;When does the foreground service actually show up? (clearing the confusion)&lt;/h2&gt;
&lt;p&gt;This confuses almost everyone, so let us nail it down. Remember one thing: &lt;strong&gt;that notification IS the foreground service.&lt;/strong&gt; Visible means the service is running. Gone means it has stopped. There is no hidden third state.&lt;/p&gt;
&lt;h3 id=&quot;when-it-starts&quot;&gt;When it starts&lt;/h3&gt;
&lt;p&gt;The foreground service starts only when the app is &lt;strong&gt;actively tracking&lt;/strong&gt;, which means when the person is in the &lt;strong&gt;MOVING&lt;/strong&gt; state. The flow looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/d47b0825-e5d2-4231-9a20-1309bad0f269.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;So the notification is not meant to be visible all the time. It comes and goes depending on whether the person is moving. This is deliberate, not a bug. When the person sits still, GPS and the service both shut down, the phone sleeps, and the battery is safe.&lt;/p&gt;
&lt;h3 id=&quot;if-it-stops-notification-disappears-is-that-a-problem-&quot;&gt;If it stops (notification disappears), is that a problem?&lt;/h3&gt;
&lt;p&gt;No. The notification disappearing does NOT mean tracking has died. It only means the app went to sleep in stationary mode. The OS still holds the two triggers, the geofence and the motion trigger. The moment the person moves, it wakes the app, the service restarts, and the notification comes back.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Notification state &lt;/th&gt;&lt;th&gt;What it actually means&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Visible &lt;/td&gt;&lt;td&gt;The app is actively tracking with GPS (moving)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gone &lt;/td&gt;&lt;td&gt;The app is asleep (stationary), waiting for a trigger. Tracking is NOT dead&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;So if the notification vanishes during testing, do not panic. Move around and it will reappear. Tracking was alive the whole time, just sleeping cheaply.&lt;/p&gt;
&lt;h3 id=&quot;if-you-want-the-notification-to-stay-visible-always&quot;&gt;If you want the notification to stay visible always&lt;/h3&gt;
&lt;p&gt;If you would rather the user always sees that tracking is on, the plugin can be configured so the foreground service runs continuously for as long as tracking is enabled. The notification then stays permanent. The trade off is more battery usage. So you have two modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Battery friendly:&lt;/strong&gt; notification appears only while moving. Best battery life.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always visible:&lt;/strong&gt; notification stays up the whole time tracking is enabled. More transparent and more reliable on aggressive phones, but uses more battery.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One caution: on newer Android (12 and above) Google restricts starting a foreground service from the background, so the exact come-and-go behavior can vary a little by Android version and plugin version. The plugin handles this internally, but always test on your real target phones to confirm the notification behaves the way you expect.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-different-way-real-apps-do-this-a-teardown-story-&quot;&gt;A different way real apps do this (a teardown story)&lt;/h2&gt;
&lt;p&gt;Everything above uses &lt;code&gt;flutter_background_geolocation&lt;/code&gt;, which is the gold standard. But many tracking apps in the wild do NOT use it. They build a simpler version by hand, and it is worth understanding because you will meet it far more often than you expect.&lt;/p&gt;
&lt;p&gt;There are two ways to build that simpler version. One stays entirely in Dart and leans on the &lt;code&gt;geolocator&lt;/code&gt; plugin. The other drops into native Android and iOS code and talks to Flutter through channels, which gives you far more control and lets you read signals the plugin never surfaces. Let us walk through both.&lt;/p&gt;
&lt;h3 id=&quot;way-1-the-dart-only-approach-the-geolocator-plugin-&quot;&gt;Way 1: The Dart-only approach (the geolocator plugin)&lt;/h3&gt;
&lt;p&gt;Instead of the geofence-sleep engine, these apps use the basic &lt;code&gt;geolocator&lt;/code&gt; package to read positions and wrap it in their own foreground service that stays on the whole time tracking is enabled. No sleeping behind a geofence. The service starts, holds a wake lock, and streams positions on a distance filter until tracking is turned off.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/e8d88080-b0d0-4f54-b3a5-993e55833696.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Why build it this way? It is simple, it has no license cost, and often you do not need the app to survive a swipe-away for days. You just need reliable tracking during an active shift, which this handles fine.&lt;/p&gt;
&lt;p&gt;The trade-offs you must know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;It burns more battery, because GPS and the service run continuously instead of sleeping behind a geofence.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Surviving a hard force-quit is weaker. These apps lean on &lt;code&gt;stopWithTask=false&lt;/code&gt;, a boot receiver and a battery-optimisation exemption, but they do not have the Play Services geofence resurrection trick. So a fully killed process can simply stop until the user opens the app again.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It is much easier to reason about and debug, which is a real advantage when you are shipping fast.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;br&gt;
If your tracking only needs to be reliable during a known active window, like a shift, a delivery, or a site visit, the simple always-on service is often the pragmatic choice. Save the full geofence engine for true all-day passive tracking where battery life is the main worry.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;way-2-the-native-approach-with-method-and-event-channels&quot;&gt;Way 2: The native approach with method and event channels&lt;/h3&gt;
&lt;p&gt;The Dart-only way is easy, but it only gives you what the plugin chooses to expose. For full control you drop into native code: location logic in Kotlin on Android and Swift on iOS, your own foreground service, results sent up to Flutter through a channel. Serious tracking apps usually land here, because the richest signals live in the native layer, not in Dart.&lt;/p&gt;
&lt;h4 id=&quot;method-channel-versus-event-channel-in-one-line-each&quot;&gt;Method channel versus event channel, in one line each&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;MethodChannel&lt;/strong&gt; is a request and a response. Dart calls something like &lt;code&gt;startTracking&lt;/code&gt;, native runs it and replies once. It is made for commands: start, stop, or ask a one-time question.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An &lt;strong&gt;EventChannel&lt;/strong&gt; is a continuous stream. Native keeps pushing values, and Dart keeps listening. This is the perfect fit for a live location feed, because points arrive again and again.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/7bd742dd-38dd-4670-81a7-37353475d301.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-signals-you-can-grab-natively-and-why-they-matter&quot;&gt;The signals you can grab natively, and why they matter&lt;/h4&gt;
&lt;p&gt;In native code, each update can carry much more than latitude and longitude. These extra fields make a track trustworthy and easy to debug:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Speed&lt;/strong&gt; in metres per second, straight from the GPS fix. Catches impossible jumps and tells walking from driving.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;isMocked&lt;/strong&gt;, the real spoof flag, read right at the source instead of through a wrapper.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Device info&lt;/strong&gt; like model and manufacturer, so you can spot the cheap OEM phones that love to kill background apps.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Battery level and charging state&lt;/strong&gt;, so your server understands that a dead battery, not a bug, is why the points stopped arriving.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Bundling all of these into every event turns a plain coordinate into a record you can actually trust.&lt;/p&gt;
&lt;h4 id=&quot;android-native-kotlin-&quot;&gt;Android native (Kotlin)&lt;/h4&gt;
&lt;p&gt;Inside your own foreground service, you use the FusedLocationProviderClient. On each update, you build a map with the extra signals and push it into the EventChannel sink.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// inside your location EventChannel StreamHandler
private val locationCallback = object : LocationCallback() {
    override fun onLocationResult(result: LocationResult) {
        val location = result.lastLocation ?: return

        // read the spoof flag at the source
        val isMock = if (Build.VERSION.SDK_INT &amp;gt;= Build.VERSION_CODES.S) {
            location.isMock              // API 31 and above
        } else {
            @Suppress(&quot;DEPRECATION&quot;)
            location.isFromMockProvider  // older devices
        }

        // battery level right now
        val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        val batteryLevel = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)

        val data = mapOf(
            &quot;latitude&quot; to location.latitude,
            &quot;longitude&quot; to location.longitude,
            &quot;speed&quot; to location.speed,          // metres per second
            &quot;accuracy&quot; to location.accuracy,
            &quot;isMocked&quot; to isMock,
            &quot;deviceModel&quot; to Build.MODEL,
            &quot;manufacturer&quot; to Build.MANUFACTURER,
            &quot;batteryLevel&quot; to batteryLevel,
            &quot;timestamp&quot; to location.time
        )

        // send this event up to Dart
        eventSink?.success(data)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;ios-native-swift-&quot;&gt;iOS native (Swift)&lt;/h4&gt;
&lt;p&gt;On iOS you use CLLocationManager. The mock story is different here. Plain iOS does not hand you a mock provider flag the way Android does, but from iOS 15 you can check whether a fix was produced by a simulator through the location's sourceInformation.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func locationManager(_ manager: CLLocationManager,
                     didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.last else { return }

    // spoof hint on iOS 15 and above
    var isMocked = false
    if #available(iOS 15.0, *) {
        isMocked = location.sourceInformation?.isSimulatedBySoftware ?? false
    }

    // battery level right now
    UIDevice.current.isBatteryMonitoringEnabled = true
    let batteryLevel = Int(UIDevice.current.batteryLevel * 100)

    let data: [String: Any] = [
        &quot;latitude&quot;: location.coordinate.latitude,
        &quot;longitude&quot;: location.coordinate.longitude,
        &quot;speed&quot;: location.speed,             // metres per second, -1 if unknown
        &quot;accuracy&quot;: location.horizontalAccuracy,
        &quot;isMocked&quot;: isMocked,
        &quot;deviceModel&quot;: UIDevice.current.model,
        &quot;batteryLevel&quot;: batteryLevel,
        &quot;timestamp&quot;: location.timestamp.timeIntervalSince1970
    ]

    // send this event up to Dart
    eventSink?(data)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-dart-side-just-listening&quot;&gt;The Dart side, just listening&lt;/h4&gt;
&lt;p&gt;On the Flutter side you do almost nothing. You open the same two channels and listen. All the heavy lifting already happened natively, so Dart only reads a clean event.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class NativeTracking {
  static const _commands = MethodChannel('app/tracking/commands');
  static const _events = EventChannel('app/tracking/events');

  Future&amp;lt;void&amp;gt; start() =&amp;gt; _commands.invokeMethod('startTracking');
  Future&amp;lt;void&amp;gt; stop() =&amp;gt; _commands.invokeMethod('stopTracking');

  Stream&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt; get locationStream =&amp;gt;
      _events.receiveBroadcastStream().map((e) =&amp;gt; Map&amp;lt;String, dynamic&amp;gt;.from(e));
}

// usage
final tracking = NativeTracking();
tracking.locationStream.listen((data) {
  if (data['isMocked'] == true) {
    // reject, native already flagged this point as fake
    return;
  }
  print('lat ${data['latitude']}, speed ${data['speed']}, '
      'battery ${data['batteryLevel']}, device ${data['deviceModel']}');
});
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;br&gt;
Do the mock check, the speed sanity check, and the device and battery reads in native code, not in Dart. That is where the real signals live, and it means the event that reaches Dart is already clean and trustworthy. The Dart layer should just consume a validated event, not try to police it after the fact.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;which-way-should-you-pick-&quot;&gt;Which way should you pick?&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You want &lt;/th&gt;&lt;th&gt;Go with&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Fastest to ship, standard needs &lt;/td&gt;&lt;td&gt;Way 1, the geolocator plugin in pure Dart&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Full control, richer signals, your own foreground service and spoof logic &lt;/td&gt;&lt;td&gt;Way 2, native code through method and event channels&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Most apps start on Way 1 and move to Way 2 only when they hit a wall, like needing a signal the plugin does not expose, or tighter control over the service and the spoof checks.&lt;/p&gt;
&lt;h3 id=&quot;the-piece-the-earlier-sections-skipped-stopping-fake-gps&quot;&gt;The piece the earlier sections skipped: stopping fake GPS&lt;/h3&gt;
&lt;p&gt;Everything above assumed the location you receive is honest. Often it is not. Someone can install a fake-GPS app and punch in from home while the map shows the office. If location decides money in your app, like attendance, travel allowance, or delivery proof, you have to defend against this or the whole feature is meaningless.&lt;/p&gt;
&lt;p&gt;Every Android location object carries a simple flag that tells you whether it came from a mock provider. In the &lt;code&gt;geolocator&lt;/code&gt; package, each &lt;code&gt;Position&lt;/code&gt; exposes &lt;code&gt;isMocked&lt;/code&gt;. Checking it is the cheapest first line of defence you can add.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:geolocator/geolocator.dart';

Future&amp;lt;void&amp;gt; readLocation() async {
  Position position = await Geolocator.getCurrentPosition(
    desiredAccuracy: LocationAccuracy.high,
  );

  // Android marks positions that came from a fake GPS app
  if (position.isMocked) {
    // do not trust this point, flag the user and reject it
    print('Mocked location detected, rejecting this point');
    return;
  }

  print('Trusted location: ${position.latitude}, ${position.longitude}');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Real apps go further and refuse to run at all on devices that make spoofing easy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Rooted or jailbroken devices are blocked, because root makes faking location trivial and hard to detect.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Emulators are blocked, since an emulator can report any coordinate you type into it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Developer Mode being switched on is flagged, because most mock-location apps need it enabled.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/5f3c6e45-3d6b-4012-9024-8b25516172cd.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;isMocked&lt;/code&gt; is necessary but not enough on its own. A rooted device can hide the mock flag entirely. That is why serious apps combine the flag with root and emulator checks, and add a server-side sanity check too, like rejecting impossible speed between two points or a location that jumps across cities in a few seconds. Trust a position only after it clears all of these gates.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;live-tracking-versus-store-and-forward&quot;&gt;Live tracking versus store and forward&lt;/h3&gt;
&lt;p&gt;Earlier we said: save every point to SQLite first, then sync separately. That is right for route logging, where you want a complete gap-free history.&lt;/p&gt;
&lt;p&gt;Some apps have a different need. An admin wants to watch a live map and see people move right now, and the store-and-forward queue is too slow for that. These apps push each position straight into a realtime database, usually Firebase Realtime Database, so the dashboard updates the instant a point arrives.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Approach &lt;/th&gt;&lt;th&gt;Best for &lt;/th&gt;&lt;th&gt;Latency &lt;/th&gt;&lt;th&gt;Offline behaviour&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Store and forward (local SQLite queue) &lt;/td&gt;&lt;td&gt;Route history, reports, payroll proof &lt;/td&gt;&lt;td&gt;Seconds to minutes &lt;/td&gt;&lt;td&gt;Points wait safely and sync later in order&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Live push (realtime database) &lt;/td&gt;&lt;td&gt;An admin watching a live map &lt;/td&gt;&lt;td&gt;Almost instant &lt;/td&gt;&lt;td&gt;Needs a fallback, a purely live point can be lost with no connection&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The two are not enemies. A robust app does both: push live for the dashboard, and still keep a local SQLite copy so a dead zone never punches a hole in the history. Live for the eyes, local for the record.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/dcfad1b8-f14d-4dfb-b69c-25fb2f30246d.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-to-copy-and-what-to-avoid&quot;&gt;What to copy and what to avoid&lt;/h3&gt;
&lt;p&gt;Good ideas worth stealing from apps that ship this successfully:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Check &lt;code&gt;isMocked&lt;/code&gt; on every point and reject mocked ones.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Block, or at least flag, rooted and emulator devices for any money-critical tracking.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Consider a live push to a realtime database when someone genuinely needs to watch a map, but keep the local queue as well.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use &lt;code&gt;stopWithTask=false&lt;/code&gt; plus a boot receiver so a swipe-away or a reboot does not silently end a shift.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Mistakes worth avoiding, all of which are common in shipped apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Shipping cleartext HTTP (&lt;code&gt;usesCleartextTraffic=true&lt;/code&gt;) so that location data travels unencrypted. Always send coordinates over HTTPS.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Leaving a local test URL, like an &lt;code&gt;http://192.168.x.x&lt;/code&gt; endpoint, hardcoded in the release build.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hardcoding unrestricted map API keys in the app. Restrict every key by package name and signing certificate, and by the specific API it is allowed to call, or someone will run up your billing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Trusting the phone's location blindly with no spoof defence at all.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The one line to remember from this teardown&lt;/strong&gt;&lt;br&gt;
The fancy plugin is not the only way, and it is not always the right way. Pick your architecture from the need. Simple always-on service for a bounded shift, full geofence engine for all-day passive tracking. And whatever you build, if location decides money, never trust a coordinate until it has proven it is not faked.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-third-wake-up-style-timer-driven-periodic-sampling&quot;&gt;The third wake-up style: timer-driven periodic sampling&lt;/h2&gt;
&lt;p&gt;So far you have seen two ways an app gets woken up: the geofence and motion approach, where the OS wakes you because you moved, and the continuous foreground service, where you stay awake the whole time. There is a third style, and it answers a specific question: &quot;the phone is in deep sleep, but I still want a location every few minutes.&quot; This is timer-driven or periodic sampling, built from two Android pieces working together, &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; and &lt;code&gt;WAKE_LOCK&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;first-why-a-normal-timer-does-not-work&quot;&gt;First, why a normal timer does not work&lt;/h3&gt;
&lt;p&gt;Your instinct might be a simple loop or a &lt;code&gt;Timer&lt;/code&gt; that fires every five minutes and grabs a location. In the background this quietly fails, and it is worth understanding why.&lt;/p&gt;
&lt;p&gt;When the phone goes idle with the screen off, it enters a power saving state called &lt;strong&gt;Doze&lt;/strong&gt;, and the CPU is put to sleep. While that happens, your app's own timers, &lt;code&gt;Future.delayed&lt;/code&gt; calls, and background loops are all &lt;strong&gt;frozen&lt;/strong&gt;. They do not fire until something wakes the CPU, and your app process can even be killed. So a plain &quot;every five minutes&quot; timer is simply not running when the phone is asleep, which is exactly when you needed it.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The key realisation&lt;/strong&gt;&lt;br&gt;
Anything that must fire while the phone sleeps cannot live inside your app's own timers. It has to be handed to the operating system, because the OS does not sleep the way your app does. That is what &lt;code&gt;AlarmManager&lt;/code&gt; is for.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;piece-one-schedule-exact-alarm-the-part-that-wakes-the-phone&quot;&gt;Piece one: SCHEDULE_EXACT_ALARM, the part that wakes the phone&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AlarmManager&lt;/code&gt; is a system service that runs a small piece of your code at a chosen time, whether your app is alive or dead. Think of it as leaving an alarm clock with the OS instead of trying to stay awake yourself. There are two flavours, and the difference matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;normal (inexact) alarm&lt;/strong&gt; gets batched and delayed by Doze to save battery. A five minute alarm might actually fire twenty minutes later. For location sampling this is useless.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An &lt;strong&gt;exact alarm&lt;/strong&gt;, using &lt;code&gt;setExactAndAllowWhileIdle&lt;/code&gt;, is allowed to fire on time even in Doze.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To use exact alarms on Android 12 and above (API 31), you must declare the &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; permission. Without it the system only gives you the inexact kind. This is exactly why tracking apps request it.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A catch worth knowing&lt;/strong&gt;&lt;br&gt;
An exact alarm is one-shot. It fires once and forgets. So every time it fires, you must schedule the next one yourself, or the cycle stops after a single tick. The pattern is: wake up, do the work, then set the next alarm before going back to sleep.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;piece-two-wake-lock-the-part-that-keeps-the-phone-awake-long-enough&quot;&gt;Piece two: WAKE_LOCK, the part that keeps the phone awake long enough&lt;/h3&gt;
&lt;p&gt;Here is the subtle part. When your alarm fires, the system gives your &lt;code&gt;BroadcastReceiver&lt;/code&gt; only a very short window to run, roughly ten seconds.&lt;/p&gt;
&lt;p&gt;But getting a GPS fix is slow and asynchronous. A cold fix can take five to thirty seconds. If you request a location and then let your receiver return, the CPU goes back to sleep &lt;strong&gt;before the fix arrives&lt;/strong&gt;, so your callback never completes or your upload dies halfway.&lt;/p&gt;
&lt;p&gt;The fix is to grab a &lt;strong&gt;partial wake lock&lt;/strong&gt; the moment your receiver runs. It keeps the CPU awake while the screen stays off, using very little battery. You hold it while you request the location, wait for the fix, save it, and upload it. Then you &lt;strong&gt;release&lt;/strong&gt; it so the phone can sleep again.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;One line to remember&lt;/strong&gt;&lt;br&gt;
The alarm wakes the phone at the right second. The wake lock keeps it awake just long enough to finish the job. One is about when, the other is about how long.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-full-loop&quot;&gt;The full loop&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.hashnode.com/uploads/covers/656736a41a36724be2388eb7/fc707193-0eec-489b-bb74-a8c0b94c3a6d.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-it-looks-like-in-kotlin&quot;&gt;What it looks like in Kotlin&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// 1. Schedule the next wake-up
fun scheduleNextWakeup(context: Context) {
    val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    val intent = Intent(context, LocationAlarmReceiver::class.java)
    val pi = PendingIntent.getBroadcast(
        context, 0, intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    val triggerAt = System.currentTimeMillis() + 5 * 60 * 1000 // 5 minutes later

    // exact, and allowed to fire even in Doze (needs SCHEDULE_EXACT_ALARM)
    am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}

// 2. The receiver: stay awake, get a location, re-arm, then sleep
class LocationAlarmReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
        val wakeLock = pm.newWakeLock(
            PowerManager.PARTIAL_WAKE_LOCK, &quot;app:LocationWakeLock&quot;
        )
        wakeLock.acquire(60_000) // safety timeout, hold at most 60 seconds

        val fused = LocationServices.getFusedLocationProviderClient(context)
        fused.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
            .addOnSuccessListener { location -&amp;gt;
                saveAndUpload(location) // your own save plus upload
            }
            .addOnCompleteListener {
                scheduleNextWakeup(context)          // re-arm the next alarm
                if (wakeLock.isHeld) wakeLock.release() // let the phone sleep
            }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;two-caveats-you-must-plan-for&quot;&gt;Two caveats you must plan for&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Doze rate-limits even exact alarms.&lt;/strong&gt; In deep Doze, &lt;code&gt;setExactAndAllowWhileIdle&lt;/code&gt; reliably fires only about once every nine minutes. So you cannot sample every thirty seconds this way, and for continuous tracking you still need a foreground service. Real apps often combine both: a foreground service while the person is active, alarm-based sampling for check-ins while the phone is idle.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Battery optimisation can block it.&lt;/strong&gt; Requesting &lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt; and guiding the user to allow it makes these alarms fire far more reliably, especially on the aggressive cheap OEM phones we keep coming back to.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;so-where-does-this-fit-among-the-three-styles-&quot;&gt;So where does this fit among the three styles?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Wake-up style &lt;/th&gt;&lt;th&gt;What triggers a location &lt;/th&gt;&lt;th&gt;Best for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Geofence and motion (the plugin way) &lt;/td&gt;&lt;td&gt;You physically move &lt;/td&gt;&lt;td&gt;All-day passive tracking with great battery life&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Continuous foreground service &lt;/td&gt;&lt;td&gt;Nothing, it never sleeps &lt;/td&gt;&lt;td&gt;Dense, precise tracking during an active shift&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timer-driven periodic sampling &lt;/td&gt;&lt;td&gt;A clock, every few minutes &lt;/td&gt;&lt;td&gt;Occasional check-ins while the phone is idle, cheaper than staying awake&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The takeaway&lt;/strong&gt;&lt;br&gt;
Exact alarms plus a wake lock are the tool when you want a location on a schedule rather than on movement. The alarm handles when to wake, the wake lock handles staying awake long enough to finish, and you re-arm the alarm on every tick. Just remember Doze will not let you sample faster than roughly every nine minutes, so pair it with a foreground service whenever you need something tighter.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26458/tracking-from-the-grave-how-apps-follow-your-location-after-theyre-killed</guid>
<pubDate>Tue, 08 Sep 2026 06:06:38 +0000</pubDate>
</item>
<item>
<title>Automate Unused Asset Cleanup in Flutter</title>
<link>https://coderlegion.com/26143/automate-unused-asset-cleanup-in-flutter</link>
<description>&lt;p&gt;Let's be realistic, every Flutter project accumulates dead weight over time, big images not deleted, a logo variant from a rebrand, a placeholder image a dev forgot to delete.&lt;br&gt;
Individually they're small but they add up to real APK/IPA size.&lt;/p&gt;
&lt;p&gt;I built &lt;code&gt;cleanbev&lt;/code&gt; to fix that locally, and I now a new upgrade is available a GitHub Action so the check runs automatically on every pull request, instead of running it localy.&lt;/p&gt;
&lt;h3 id=&quot;what-cleanbev-actually-does&quot;&gt;What cleanbev actually does&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cleanbev&lt;/code&gt; is a command-line tool for Dart and Flutter projects. It:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reads your &lt;code&gt;pubspec.yaml&lt;/code&gt; to find every declared asset&lt;/li&gt;
&lt;li&gt;Scans your Dart code for references to each one&lt;/li&gt;
&lt;li&gt;Reports anything declared but never referenced&lt;/li&gt;
&lt;li&gt;Optionally deletes what you confirm&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Locally, it's interactive:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dart pub global activate cleanbev
dart pub global run cleanbev --assets-path assets/images
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;✓ Asset assets/images/logo_white_bg.png is not used in the project. Do you want to delete it? · no
Asset assets/images/logo_white_bg.png has not been deleted.

✓ Asset assets/images/download_image-car.jpeg is not used in the project. Do you want to delete it? · yes
Asset assets/images/download_image-car.jpeg has been deleted.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's also available as an MCP server, so editor-integrated agents (VS Code, Cursor, Visual Studio) can call it directly while you're working.&lt;/p&gt;
&lt;h3 id=&quot;the-gap-someone-could-forgot-to-cleanup-some-assets-before-pushing&quot;&gt;The gap: someone could forgot to cleanup some assets before pushing&lt;/h3&gt;
&lt;p&gt;A local CLI tool only helps if someone remembers to run it but in practice, unused-asset cleanup falls into the same bucket as &quot;update the changelog&quot; or &quot;check for outdated dependencies&quot; but under&lt;br&gt;
deadline pressure this could be skipped.&lt;br&gt;
&lt;strong&gt;The only way it reliably happens is if CI enforces it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That's what the new &lt;strong&gt;Cleanbev Asset Check&lt;/strong&gt; action does.&lt;/p&gt;
&lt;h3 id=&quot;using-it&quot;&gt;Using it&lt;/h3&gt;
&lt;p&gt;Add this into any Flutter repo's &lt;code&gt;.github/workflows/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Check Unused Assets

on:
  pull_request:
    branches: [&quot;main&quot;]

jobs:
  cleanbev-asset-check:
    name: Cleanbev Asset Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Cleanbev
        uses: koukibadr/cleanbev-action@v1
        with:
          assets-path: &quot;assets&quot;
          blocking: &quot;true&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;inputs&quot;&gt;Inputs&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Input &lt;/th&gt;&lt;th&gt;Description &lt;/th&gt;&lt;th&gt;Default&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;assets-path&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Path to the assets directory to scan &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;blocking&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Fail the job when unused assets are found &lt;/td&gt;&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;two-ways-to-run-it&quot;&gt;Two ways to run it&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;blocking: &quot;false&quot;&lt;/code&gt;&lt;/strong&gt; (default) — reports what it finds, cleans it up, never fails the build. Good for a low-friction &quot;just keep things tidy&quot; job on a schedule or on pushes to &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;blocking: &quot;true&quot;&lt;/code&gt;&lt;/strong&gt; — turns it into a real PR gate. If a PR introduces an asset that's never referenced, the check fails until it's cleaned up or actually used. This is what I run on pull requests.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Action: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/marketplace/actions/cleanbev-asset-check&quot;&gt;github.com/marketplace/actions/cleanbev-asset-check&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;CLI on pub.dev: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/cleanbev&quot;&gt;pub.dev/packages/cleanbev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MCP server on PyPI: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/cleanbev-mcp&quot;&gt;pypi.org/project/cleanbev-mcp&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's a small tool solving a small, annoying problem. If you try it on a real project and hit a bug please send me your issues and PRs.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26143/automate-unused-asset-cleanup-in-flutter</guid>
<pubDate>Thu, 03 Sep 2026 09:23:34 +0000</pubDate>
</item>
<item>
<title>Flutter vs React Native for MVP: Which One Should You Actually Choose?</title>
<link>https://coderlegion.com/26095/flutter-vs-react-native-for-mvp-which-one-should-you-actually-choose</link>
<description>&lt;p&gt;You've validated the idea. You've got a rough spec, maybe a Figma file, and a deadline that's tighter than you'd like. Now you're stuck on a decision that feels bigger than it should: Flutter or React Native.&lt;/p&gt;
&lt;p&gt;We get why this question causes so much hesitation. Picking wrong doesn't just mean a slower app; it can mean rebuilding parts of it six months in, paying more for developers than you budgeted, or watching your MVP timeline slip because the framework didn't fit your team's skill set. For a founder trying to get to market before the runway runs out, that's not a small mistake.&lt;/p&gt;
&lt;p&gt;This isn't a &quot;&lt;strong&gt;Flutter is better&lt;/strong&gt;&quot; or &quot;&lt;strong&gt;React Native is better&lt;/strong&gt;&quot; article. It's a breakdown of what actually determines the right choice for an MVP- specifically, not for a five-year-old enterprise app, not for a game studio, but for a small team trying to ship something real, get it in front of users, and learn fast.&lt;/p&gt;
&lt;h2 id=&quot;the-real-question-isn-t-which-framework-is-better-&quot;&gt;The Real Question Isn't &quot;Which Framework Is Better&quot;&lt;/h2&gt;
&lt;p&gt;It's &quot;which framework fits your constraints.&quot; For an MVP, three constraints matter more than anything else:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How fast can you build and test&lt;/li&gt;
&lt;li&gt;What talent you can actually hire or already have&lt;/li&gt;
&lt;li&gt;How much native-feeling polish your app needs on day one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Framework benchmarks and GitHub star counts don't answer any of those. Your team, your budget, and your app's complexity do. If you want a broader look at how Flutter and React Native compare across performance, cost, platform support, and scalability, our &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nebulatechit.com/blogs/react-native-vs-flutter/&quot;&gt;React Native vs Flutter comparison&lt;/a&gt; covers those differences in more detail. &lt;/p&gt;
&lt;h2 id=&quot;flutter-what-it-s-actually-good-at&quot;&gt;Flutter: What It's Actually Good At&lt;/h2&gt;
&lt;p&gt;Flutter, built by Google, compiles to native code and uses its own rendering pipeline rather than relying entirely on native OS UI components. On iOS and Android API 29+, Flutter's Impeller rendering engine is the default, giving the framework a rendering pipeline designed for predictable performance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistent UI across platforms&lt;/strong&gt;. A button looks and behaves the same on iOS and Android because Flutter draws it itself. For MVPs with a strong design system or a founder who cares about pixel-level consistency, this is a real advantage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strong performance for animation-heavy apps.&lt;/strong&gt; If your MVP leans on custom transitions, gestures, or visual polish, Flutter tends to handle that more predictably than React Native, since it isn't bridging through native UI components.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Single codebase, genuinely.&lt;/strong&gt; Flutter's &quot;write once&quot; promise holds up better in practice than it used to. Widget-based architecture means less platform-specific patching for most standard app types.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ecosystem familiarity matters when hiring.&lt;/strong&gt; Developer surveys such as the &lt;a rel=&quot;nofollow&quot; href=&quot;https://survey.stackoverflow.co/2024/&quot;&gt;Stack Overflow Developer Survey&lt;/a&gt; provide a useful snapshot of the technologies developers use and want to learn, which can help put your team's existing skills and hiring options into context. &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; Flutter apps are written in Dart, a language most web developers haven't touched. If your existing team knows JavaScript or TypeScript, you're either training them or hiring fresh, and Dart talent is a smaller pool than JavaScript talent in most markets.&lt;/p&gt;
&lt;h2 id=&quot;react-native-what-it-s-actually-good-at&quot;&gt;React Native: What It's Actually Good At&lt;/h2&gt;
&lt;p&gt;React Native, maintained by Meta, uses JavaScript or TypeScript, and its New Architecture includes Fabric for rendering and direct communication between JavaScript and native code through JSI. That gives it a different set of strengths:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Access to a broader pool of JavaScript/TypeScript talent.&lt;/strong&gt; If you already have web developers who know React, the learning curve to React Native is short. For early-stage teams that can't afford long onboarding, this matters more than most technical comparisons.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Faster initial builds for standard app patterns.&lt;/strong&gt; Forms, lists, navigation, and API-driven screens the bread and butter of most MVPs are well-trodden territory in React Native, with a large ecosystem of libraries already built for these use cases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Easier path to a companion web app.&lt;/strong&gt; If there's any chance your MVP will need a web version later, React skills transfer more directly than Dart skills.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; React Native can still require additional optimization for highly animated, graphics-heavy, or gesture-intensive experiences, particularly when your app relies heavily on platform-specific functionality or complex native integrations. For a straightforward CRUD-style MVP, this is less likely to be a deciding factor. &lt;/p&gt;
&lt;h2 id=&quot;where-founders-get-this-decision-wrong&quot;&gt;Where Founders Get This Decision Wrong&lt;/h2&gt;
&lt;p&gt;We see the same few mistakes repeatedly when teams choose a framework for an MVP:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choosing based on long-term scale instead of MVP speed.&lt;/strong&gt; An MVP's job is to test a hypothesis, not to be future-proof for a million users. Optimizing for hypothetical scale before you have real usage data solves a problem you might never have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ignoring existing team skills.&lt;/strong&gt; If you already have a React or JavaScript-heavy team and choose Flutter because a benchmark article said it's faster, you've added a learning curve to an already tight timeline. That cost is real and often larger than the performance gap you were chasing. Research on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mdpi.com/2227-9709/12/2/45&quot;&gt;Flutter and React Native developer trends&lt;/a&gt; also highlights differences in developer satisfaction and job-market demand, reinforcing why your team's existing skills should factor into the decision. &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treating this as a permanent decision.&lt;/strong&gt; Your MVP doesn't need to solve every future technical requirement on day one. But switching frameworks later still means significant redevelopment, so the goal should be to make a sensible decision now, not assume you'll simply rebuild when the product grows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skipping a technical spike.&lt;/strong&gt; Before committing, building a tiny proof-of-concept one core screen, one core interaction in both frameworks for a day or two can surface friction that no comparison article will predict for your specific use case.&lt;/p&gt;
&lt;h2 id=&quot;a-practical-way-to-decide&quot;&gt;A Practical Way to Decide&lt;/h2&gt;
&lt;p&gt;If you're still unsure, this is roughly how we'd narrow it down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If your team already knows JavaScript/React, and your MVP is mostly forms, lists, and API calls, lean React Native.&lt;/li&gt;
&lt;li&gt;If your MVP depends heavily on custom animation, a highly branded UI, or gesture-based interaction, lean Flutter.&lt;/li&gt;
&lt;li&gt;If you're hiring contractors and want the largest available talent pool, lean React Native.&lt;/li&gt;
&lt;li&gt;If highly consistent rendering and custom visual interactions matter more than developer availability, lean Flutter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither framework is a wrong answer for most MVPs. The wrong answer is picking one for reasons that don't match your actual constraints.&lt;/p&gt;
&lt;p&gt;For teams weighing this decision alongside broader technical planning, work through this exact evaluation with founders before writing a single line of code, mapping the framework choice to the team, budget, and timeline rather than a generic recommendation.&lt;/p&gt;
&lt;h2 id=&quot;the-bottom-line&quot;&gt;The Bottom Line&lt;/h2&gt;
&lt;p&gt;Neither Flutter nor React Native is objectively better; they’re better for different situations. The right choice for your MVP comes down to what your team already knows, how visually complex your app needs to be, and how much you’re prioritizing speed versus long-term flexibility.&lt;/p&gt;
&lt;p&gt;If you’re still weighing the trade-offs, the most useful next step isn’t reading another comparison. Build a small proof-of-concept in whichever framework you’re leaning toward and see how it actually feels to work with for a day. That will tell you more than any benchmark.&lt;/p&gt;
&lt;h2 id=&quot;faqs&quot;&gt;FAQs&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. Is Flutter or React Native faster to build an MVP with?&lt;/strong&gt;&lt;br&gt;
 It depends on your team's existing skills more than the framework itself. A JavaScript-fluent team will usually move faster in React Native; a team with no strong language preference may find Flutter's tooling and hot reload just as fast, sometimes faster for UI-heavy screens.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Which framework is cheaper for an MVP?&lt;/strong&gt;&lt;br&gt;
Cost tracks talent availability more than the technology itself. React Native developers are generally more abundant, which can mean more competitive rates, particularly for freelance or contract hires.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Can I switch frameworks later if I start with the wrong one?&lt;/strong&gt;&lt;br&gt;
Yes, though it means a rebuild of the front end, not a simple migration. Many teams treat the MVP framework choice as temporary by design, planning to revisit it once the product has real user data and funding.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Does Flutter or React Native perform better?&lt;/strong&gt;&lt;br&gt;
For most standard MVP app types forms, dashboards, content feeds the performance difference is negligible to end users. The gap widens for animation-heavy or graphics-intensive apps, where Flutter tends to have an edge. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26095/flutter-vs-react-native-for-mvp-which-one-should-you-actually-choose</guid>
<pubDate>Wed, 02 Sep 2026 21:53:13 +0000</pubDate>
</item>
<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>Nostr as a backend out of the box: where it fits and where it doesn't</title>
<link>https://coderlegion.com/22603/nostr-as-a-backend-out-of-the-box-where-it-fits-and-where-it-doesnt</link>
<description>&lt;hr&gt;
&lt;p&gt;title: &quot;Nostr as a backend out of the box: where it fits and where it doesn't&quot;&lt;br&gt;
published: true&lt;br&gt;
description: &quot;A hands-on review of Nostr as an out-of-the-box backend across real cases: notes, chats, feeds, communities, and organizations.&quot;&lt;br&gt;
tags: nostr, decentralization, backend, Flutter&lt;br&gt;
cover_image: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/hhp9ymdzovelglq8a0m1.png&quot;&gt;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/hhp9ymdzovelglq8a0m1.png&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;canonical-url-not-needed-dev-to-is-the-original-home-of-this-english-version&quot;&gt;canonical_url: not needed — dev.to is the original home of this English version&lt;/h2&gt;
&lt;hr&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Nostr is a ready-to-use, decentralized backend: it gives you an event model, signatures, encryption, and sync through relays. For notes, private chats, and simple feeds it covers almost everything — with no server of your own. But once you need a smart feed, roles, hierarchies, or complex moderation, the protocol hits one basic limit: only the owner of a key can change an event. After that, the extra workarounds quickly cost more than they save. Below I look at real cases: where Nostr works well, and where it is better to think about a separate backend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;introduction&quot;&gt;Introduction&lt;/h4&gt;
&lt;p&gt;For a long time I wanted to build a simple cross-platform notes app.&lt;br&gt;
The most important use case for me was storing sensitive information, such as passwords, crypto wallet addresses, and so on. Another key requirement was reliable sync between devices, in particular iOS, Android, and macOS.&lt;/p&gt;
&lt;p&gt;But there was one big problem: for a long time I could not find a secure way to sync notes that did not depend on one specific provider.&lt;/p&gt;
&lt;p&gt;Some time ago I happened to join a project built around the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips&quot;&gt;Nostr&lt;/a&gt; protocol — that is how I first learned about it. For that project Nostr was, to be honest, not a great fit (you will see why later in the article). But for my notes task it turned out to be an almost perfect solution.&lt;/p&gt;
&lt;p&gt;The goal of this article is to review the good and the bad cases of using the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips&quot;&gt;Nostr&lt;/a&gt; protocol that I ran into myself. I also want to describe the tasks it is worth using for, and the tasks where you should look for other solutions.&lt;/p&gt;
&lt;h4 id=&quot;what-the-nostr-protocol-is-and-how-it-works&quot;&gt;What the Nostr protocol is and how it works&lt;/h4&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips&quot;&gt;Nostr&lt;/a&gt; protocol presents itself as a backend for building decentralized social networks that are hard to censor. But, as you will see below, it can be used for a wider range of tasks. That said, building a social network in the usual sense (with complex feed ranking and filtering, and good performance on complex post models) is, in my personal opinion, hard.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips&quot;&gt;Nostr&lt;/a&gt; protocol is a set of rules (NIPs) for talking to a decentralized network of servers (relays). You talk to them over WebSockets. Each server keeps a database of events. An event is a record with a fixed structure; the client and the relay exchange it as a JSON object with a defined schema. A user can read and publish such events from one or more relays (and to one or more relays). Each event has an id — a hash of the event itself (more precisely, a SHA-256 of a serialized set of fields: &lt;code&gt;pubkey&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt;, and &lt;code&gt;content&lt;/code&gt;), not just of its content. So when you get the same event from different relays, the id is used to remove duplicates. Each event also has a signature. The signature is made from the event id and the sender's private key, and the JSON event has a field with the sender's public key. So to publish an event to one or more relays you need this key pair. (To read events you do not need any keys.)&lt;/p&gt;
&lt;p&gt;It is easy to see that a key pair can also be used for asymmetric encryption. The spec describes common ways to encrypt chat messages — for example, the current &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/44.md&quot;&gt;Encrypted Payloads (NIP-44)&lt;/a&gt;, which the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/17.md&quot;&gt;Private Direct Messages (NIP-17)&lt;/a&gt; feature is built on. There is also an older &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/04.md&quot;&gt;Encrypted Direct Message (NIP-04)&lt;/a&gt;: it is officially marked as deprecated and is not recommended for public, decentralized use (it uses unauthenticated encryption and leaks metadata).&lt;/p&gt;
&lt;p&gt;Still, it is important to understand that NIP-44 is not the only option. The protocol does not force a specific algorithm: clients are free to agree on any compatible way to encrypt content. So for practical tasks — say, if you run your own private relay or solve a specific business task — NIP-04 with plain AES, or any other scheme you like, can be a good fit.&lt;/p&gt;
&lt;h4 id=&quot;data-model-events-kind-and-tags&quot;&gt;Data model: events, &lt;code&gt;kind&lt;/code&gt;, and tags&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;If you already know Nostr (events, &lt;code&gt;kind&lt;/code&gt;, tags, the &lt;code&gt;REQ&lt;/code&gt; subscription model), feel free to skip this section and go straight to the cases.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Before we get to the cases, it makes sense to look a bit closer at how an event is built — we will keep coming back to it.&lt;/p&gt;
&lt;p&gt;Any Nostr event is JSON that looks roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;id&quot;: &quot;1897f99...&quot;,
  &quot;pubkey&quot;: &quot;9c2e0a7...&quot;,
  &quot;created_at&quot;: 1781787656,
  &quot;kind&quot;: 1,
  &quot;tags&quot;: [
    [&quot;e&quot;, &quot;737f50...&quot;],
    [&quot;p&quot;, &quot;9c2e0a7...&quot;]
  ],
  &quot;content&quot;: &quot;Hello, Nostr!&quot;,
  &quot;sig&quot;: &quot;41b737f50...&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;kind&lt;/code&gt; field is an integer that sets the &lt;em&gt;rough&lt;/em&gt; meaning of an event, rather than a strict type. For example, &lt;code&gt;kind:0&lt;/code&gt; is a user profile, &lt;code&gt;kind:1&lt;/code&gt; is a short text note (a post), &lt;code&gt;kind:7&lt;/code&gt; is a reaction (a like). It is important that this is an agreement, not a strict schema: the meaning of each &lt;code&gt;kind&lt;/code&gt; is described in its NIP, and most NIPs have &lt;em&gt;draft&lt;/em&gt; status — so these are conventions, not a fixed standard. The relay does not validate the structure of the content: the clients that agreed with each other are fully responsible for how a &lt;code&gt;kind&lt;/code&gt; and the event format are read. You could say a &lt;code&gt;kind&lt;/code&gt; is something like a &quot;table&quot; in a normal database, but without a strict schema and without server-side validation.&lt;/p&gt;
&lt;p&gt;Besides meaning, &lt;code&gt;kind&lt;/code&gt; also sets how the relay stores the event. The exact behavior depends on the number range (the ranges are defined in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/01.md&quot;&gt;NIP-01&lt;/a&gt;). In simple terms, events fall into:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;regular&lt;/strong&gt; — range &lt;code&gt;1000–9999&lt;/code&gt; (also &lt;code&gt;kind:1&lt;/code&gt;, &lt;code&gt;kind:4&lt;/code&gt;–&lt;code&gt;44&lt;/code&gt;); they just pile up and overwrite nothing;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;replaceable&lt;/strong&gt; — range &lt;code&gt;10000–19999&lt;/code&gt; (also &lt;code&gt;kind:0&lt;/code&gt; for a profile, &lt;code&gt;kind:3&lt;/code&gt; for a contact list); the relay keeps only the newest version for a given author, and a new event replaces the old one;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;addressable&lt;/strong&gt; (also called parameterized replaceable) — range &lt;code&gt;30000–39999&lt;/code&gt;; the same as replaceable, but the &quot;coordinates&quot; for replacing also include the value of the &lt;code&gt;d&lt;/code&gt; tag (see below). This is how updatable things with an identifier work — a long note (&lt;code&gt;kind:30023&lt;/code&gt;), a community definition (&lt;code&gt;kind:34550&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ephemeral&lt;/strong&gt; — range &lt;code&gt;20000–29999&lt;/code&gt;; the relay does not have to store them at all (useful for signaling).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For addressable events the &quot;coordinates&quot; are the triple &lt;code&gt;kind:pubkey:d&lt;/code&gt;, where &lt;code&gt;d&lt;/code&gt; is the value of a special &lt;code&gt;[&quot;d&quot;, &quot;...&quot;]&lt;/code&gt; tag — a stable identifier of the thing. The &lt;code&gt;a&lt;/code&gt; tag points to such events using the &lt;code&gt;kind:pubkey:d&lt;/code&gt; format (we will see this below).&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;tags&lt;/code&gt; field is an array of arrays: the first item of each inner array is the tag name, and the rest are its values (&lt;code&gt;[&quot;e&quot;, &quot;&amp;lt;event id&amp;gt;&quot;]&lt;/code&gt;, &lt;code&gt;[&quot;p&quot;, &quot;&amp;lt;pubkey&amp;gt;&quot;]&lt;/code&gt;, and so on). Tags with single-letter names (&lt;code&gt;e&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;g&lt;/code&gt;, &lt;code&gt;d&lt;/code&gt;, …) are indexed by the relay, and you can filter queries by them — in a filter this is written as &lt;code&gt;#e&lt;/code&gt;, &lt;code&gt;#p&lt;/code&gt;, &lt;code&gt;#g&lt;/code&gt; (this will be useful in the feed section).&lt;/p&gt;
&lt;p&gt;Finally, about how events are requested. To get events, the client opens a subscription with a message like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[&quot;REQ&quot;, &quot;&amp;lt;subscription id&amp;gt;&quot;, { &amp;lt;filter&amp;gt; }, { &amp;lt;filter&amp;gt; }, ...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A filter is a JSON object with fields like &lt;code&gt;authors&lt;/code&gt;, &lt;code&gt;kinds&lt;/code&gt;, &lt;code&gt;since&lt;/code&gt;, &lt;code&gt;until&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, &lt;code&gt;#e&lt;/code&gt;, &lt;code&gt;#g&lt;/code&gt;. The relay returns all events that match at least one filter, and keeps the subscription open — it sends new matching events as they appear, until the client closes it with a &lt;code&gt;CLOSE&lt;/code&gt; message. We will come back to filters and their limits.&lt;/p&gt;
&lt;h4 id=&quot;notes&quot;&gt;Notes&lt;/h4&gt;
&lt;p&gt;While I was building my app, I did not really think about what the word Nostr stands for. Nostr is an acronym: Notes and Other Stuff Transmitted by Relays.&lt;/p&gt;
&lt;p&gt;So the idea of a notes app on top of this protocol was obvious. Nostr gives you sync, and it offers recommendations for strong asymmetric encryption — &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/44.md&quot;&gt;Encrypted Payloads (Versioned)&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So Nostr is a very good fit for this task.&lt;/p&gt;
&lt;p&gt;This is how my pet project appeared — &lt;strong&gt;Private Notes (Nostr)&lt;/strong&gt;: a cross-platform notes app with end-to-end encryption on the device (NIP-44), signing with secp256k1, and storage on relays the user picks. No server, no accounts — just a key pair. You can try it right now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;iOS:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/bg/app/private-notes-nostr/id6757975921&quot;&gt;App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexeyyupopkov.github.io&quot;&gt;alexeyyupopkov.github.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexeyyupopkov.github.io/#/apk_distribution&quot;&gt;Install Beta&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/AlexeyYuPopkov/nostr_notes&quot;&gt;github.com/AlexeyYuPopkov/nostr_notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The repository is open — if you find a bug or have an idea, feel free to open an &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/AlexeyYuPopkov/nostr_notes/issues&quot;&gt;Issue&lt;/a&gt;, I will be glad to hear from you.&lt;/p&gt;
&lt;h4 id=&quot;chats&quot;&gt;Chats&lt;/h4&gt;
&lt;p&gt;Many small mobile apps have a chat feature. For most such projects the chat backend is a third-party service like &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.twilio.com/&quot;&gt;Twilio&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://quickblox.com/&quot;&gt;Quickblox&lt;/a&gt;, or a cloud database such as &lt;a rel=&quot;nofollow&quot; href=&quot;https://firebase.google.com/docs/database/&quot;&gt;Firebase Realtime Database&lt;/a&gt; or &lt;a rel=&quot;nofollow&quot; href=&quot;https://firebase.google.com/docs/firestore/&quot;&gt;Cloud Firestore&lt;/a&gt;. But this means extra costs for these subscriptions. In the second case you also need to design the database for chat data, and that means extra work and bugs. Nostr, on the other hand, gives you a ready backend that fits chats well. You can either use the many public relays that already work, or run your own on your own backend by downloading a popular implementation from GitHub.&lt;/p&gt;
&lt;p&gt;One more thing worth mentioning is media content in chat messages. A Nostr event is always JSON with a fixed schema, and the protocol does not expect you to store binary data (images, files) inside an event. To solve this there is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/96.md&quot;&gt;NIP-96&lt;/a&gt; — a standard that describes HTTP File Storage: a spec for file hosts compatible with Nostr. The client uploads a file to a NIP-96 server, gets a URL, and puts it in the event content. Public NIP-96 servers exist, and in general this works.&lt;/p&gt;
&lt;p&gt;But in practice, if a project already uses central infrastructure (AWS S3, Firebase Cloud Storage, and so on), it is simpler and safer to store media there and just put a link in the Nostr event. It is a plain approach, but for most products it is a reasonable one.&lt;/p&gt;
&lt;p&gt;For one-on-one (one2one) chats Nostr gives you everything out of the box. But for group chats there are limits in practice, and getting around them needs extra logic.&lt;/p&gt;
&lt;p&gt;I would point out two such problems:&lt;br&gt;
1) the performance of message encryption&lt;br&gt;
2) group moderation&lt;/p&gt;
&lt;p&gt;The first one is this. To use asymmetric encryption (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/04.md&quot;&gt;Encrypted Direct Message&lt;/a&gt; or &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/44.md&quot;&gt;Encrypted Payloads (Versioned)&lt;/a&gt;), you have to encrypt the message separately for each user. As the number of group members grows, this can hurt both performance and the overall reliability of the approach. That said, from my own experience, for groups of 20 people or fewer I did not see real performance problems. I have read that up to 100 people should also be fine. At the same time, for extra reliability I would rather use central solutions outside the Nostr protocol to encrypt message content (even though that is less secure).&lt;/p&gt;
&lt;p&gt;The second problem is moderating the group and adding or removing members. The existing solution, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/17.md&quot;&gt;Private Direct Messages&lt;/a&gt;, is basically a group conversation without a separate, managed group object. The group's metadata (name, and so on) is not stored as one thing; it is computed from the tags of the messages themselves, and the tags of the newest message &quot;win&quot;. As a result, you cannot &quot;hard&quot; add or remove members, and in fact any member can redefine the group metadata — just by sending a new message with the tags they want. The root of the problem is in the base of the Nostr protocol: only the creator of an event can change it. So if you create an event — a chat room model — and link messages to it by a reference to the room, then only its creator can moderate that event. That means there is only one moderator. And even this solution is a bit artificial — I have not seen a NIP (a Nostr standard) for it.&lt;/p&gt;
&lt;p&gt;There is also a solution to this problem — &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/29.md&quot;&gt;NIP-29&lt;/a&gt;. It means running your own, separate relay for group moderation (though public relay-29 servers exist too, for example &lt;code&gt;wss://relay.groups.nip29.com&lt;/code&gt;). The relay itself checks whether a moderator is allowed to change the group state. Such a relay is responsible only for the group state, not for the users' messages, which are stored on normal relays. But this is, rather, a central solution (otherwise you lose the single source of truth and risk getting out of sync).&lt;/p&gt;
&lt;p&gt;Overall, relay-29 is a good central solution for group chats with roles and moderation, and it really works.&lt;/p&gt;
&lt;h4 id=&quot;feed-posts&quot;&gt;Feed, posts&lt;/h4&gt;
&lt;p&gt;In my view, a social network is first of all posts, likes, comments, and reposts.&lt;/p&gt;
&lt;p&gt;Let's try to figure out how well the Nostr protocol fits these goals and what the limits are. We already know that relays store event models and can return their JSON over WebSockets.&lt;br&gt;
For clarity, here is an example of a possible post JSON:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;    {

        &quot;created_at&quot;: 1781787656,
        &quot;id&quot;: &quot;1897f99...&quot;,
        &quot;kind&quot;: 1,
        &quot;pubkey&quot;: &quot;9c2e0a7...&quot;,
        &quot;content&quot;: &quot;Some text...&quot;,
        &quot;sig&quot;: &quot;41b737f50...&quot;,
        &quot;tags&quot;: [
			[&quot;e&quot;, &quot;737f50...&quot;],
			[&quot;e&quot;, &quot;1897f...&quot;],
			...,
			[&quot;a&quot;, &quot;30023:f72...:abcd&quot;],
			[&quot;a&quot;, &quot;30023:er34...:abcd&quot;],
			...,
			[&quot;g&quot;, &quot;u4pr&quot;],
			[&quot;g&quot;, &quot;u4pru&quot;]	
		]
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All tags are optional. Let's go over the ones in the example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;e&lt;/code&gt; — a reference to another event by its &lt;code&gt;id&lt;/code&gt;; used for threads, quotes, and replies&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a&lt;/code&gt; — a reference to an addressable (replaceable) event in the &lt;code&gt;kind:pubkey:d&lt;/code&gt; format; in the example above these are references to &lt;code&gt;kind:30023&lt;/code&gt; events (long posts / articles per &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/23.md&quot;&gt;NIP-23&lt;/a&gt;) by different authors&lt;/li&gt;
&lt;li&gt;&lt;code&gt;g&lt;/code&gt; — a &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/Geohash&quot;&gt;geohash&lt;/a&gt; of the location the post is linked to; the longer the string, the more precise the coordinate (&lt;code&gt;u4pr&lt;/code&gt; is a rough region, &lt;code&gt;u4pru&lt;/code&gt; is a bit more precise)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now, to understand the limits of Nostr, let's look at a few cases:&lt;br&gt;
1) To show, for example, posts (usually &lt;code&gt;kind:1&lt;/code&gt;) by specific authors (see &lt;code&gt;authors&lt;/code&gt;) in a feed, you send a request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[&quot;REQ&quot;, &quot;feed-sub-1&quot;, {
  &quot;authors&quot;: [&quot;9c2e0a7...&quot;, &quot;pubkey1&quot;, &quot;pubkey2&quot;, ...],
  &quot;kinds&quot;: [1],
  &quot;until&quot;: 1718700000,
  &quot;limit&quot;: 20
}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So far so good. With &lt;code&gt;until + limit&lt;/code&gt; it is easy to set up time-based pagination here, and it will work reliably.&lt;/p&gt;
&lt;p&gt;2) Now let's make it harder. Say we need to build a feed that shows posts by specific authors OR posts by users with a nearby location:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[&quot;REQ&quot;, &quot;feed-sub-2&quot;, {
  &quot;authors&quot;: [&quot;9c2e0a7...&quot;, &quot;pubkey1&quot;, &quot;pubkey2&quot;, ...],
  &quot;kinds&quot;: [1],
  &quot;until&quot;: 1718700000,
  &quot;limit&quot;: 20
},
{
  &quot;kinds&quot;: [1],
  &quot;#g&quot;: [&quot;u4pr&quot;],
  &quot;until&quot;: 1718700000,
  &quot;limit&quot;: 20
}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here a problem shows up right away. Inside one filter object all conditions work as AND — that is, authors + kinds + until must all match for one event. But to get OR between &quot;posts by the authors we want&quot; and &quot;posts with the geohash we want&quot;, you have to use two separate filters in an array. The relay then returns the union of both results.&lt;br&gt;
And this is exactly where reliable &lt;code&gt;until+limit&lt;/code&gt; pagination breaks. Each filter in the array is limited on its own: in a typical implementation the relay applies limit: 20 to each of the two filters separately (the exact behavior when merging results from several filters depends on the relay implementation), and then mixes the results. So imagine the first filter (by authors) collected 20 newer posts, and the second (by geohash) also collected 20, but older ones. The final output — after sorting by time and cutting to a common limit on the client — may take only part of one source and miss part of the other. On the next request the new &lt;code&gt;until&lt;/code&gt; is computed from the oldest post you received. For one of the filters this &lt;code&gt;until&lt;/code&gt; will be wrong: it will either cover already shown posts again, or skip part of the events that never reached the client. You get holes in the feed, and they add up with each new load.&lt;/p&gt;
&lt;p&gt;A working but more time-consuming way is pagination through a time window (&lt;code&gt;since + until&lt;/code&gt;) that grows step by step — it widens if too few events fall into it. This approach has its own downsides: an unpredictable number of requests to the relay for one feed page, trouble showing a loading indicator, and the risk of a long &quot;freeze&quot; during quiet periods.&lt;/p&gt;
&lt;p&gt;A third problem comes up at the level of the geohash filter itself: matching the &lt;code&gt;g&lt;/code&gt; tag works by exact string equality, not by prefix. If an event has only &lt;code&gt;[&quot;g&quot;, &quot;u4pr&quot;]&lt;/code&gt; (a rough location), and the filter asks for &lt;code&gt;&quot;#g&quot;: [&quot;u4pruyd&quot;]&lt;/code&gt; (a more precise one), the relay will not return that event — even though &lt;code&gt;u4pruyd&lt;/code&gt; is physically inside &lt;code&gt;u4pr&lt;/code&gt;. People get around this by publishing several geohash tags of different length (all the prefixes) on one event. But then the client that makes the request has to list all the prefixes of the area it wants — this scales poorly and bloats the filter when you try to cover a wide area.&lt;/p&gt;
&lt;p&gt;In the event model above you can see the &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;e&lt;/code&gt; tags. With them you can link a post to some other event (for example, to another post, if the post is a comment on it — usually through an &lt;code&gt;e&lt;/code&gt; tag, or a comment on some changeable event — through an &lt;code&gt;a&lt;/code&gt; tag). The &lt;code&gt;e&lt;/code&gt; and &lt;code&gt;a&lt;/code&gt; tags are described in more detail in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/01.md&quot;&gt;NIP-01&lt;/a&gt;. A post can also have likes, which are usually done through &lt;code&gt;kind=7&lt;/code&gt; events, and comments (&lt;code&gt;e&lt;/code&gt;). To get the number of likes and comments and show them in the feed, you need to open subscriptions to the matching events and then count them. So after you get the post and learn its &lt;code&gt;id&lt;/code&gt;, you send a request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[&quot;REQ&quot;, &quot;post-stats-sub&quot;, {
  &quot;kinds&quot;: [7, 1],
  &quot;#e&quot;: [&quot;1897f99...&quot;]
}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But likes and comments are far from the only things that can reference (or be referenced by) a given post. Through &lt;code&gt;e&lt;/code&gt;/&lt;code&gt;a&lt;/code&gt; tags, a post can be pointed to by reposts (&lt;code&gt;kind=6/16&lt;/code&gt;), zap receipts (&lt;code&gt;kind=9735&lt;/code&gt;), reactions with custom content besides likes, quotes inside other posts, and so on. For each kind of link you basically need a separate subscription with its own &lt;code&gt;kinds&lt;/code&gt; filter, or a wider filter with no &lt;code&gt;kinds&lt;/code&gt; limit, which you then have to filter and parse on the client. Already at this stage you can see that to fully show one post card in a feed you may need not one but a whole set of parallel requests to the relay.&lt;br&gt;
There is also a less obvious danger here. Since &lt;code&gt;e&lt;/code&gt;/&lt;code&gt;a&lt;/code&gt; links are not limited by the protocol in any way, nothing stops events from referencing each other in a cycle — post A comments on post B, which itself is marked as a comment on post A (by accident, a client bug, or on purpose, as spam or an attack). A client that naively walks the tree of linked events (for example, to build a full thread) can fall into an endless loop if it does not add protection in advance — tracking the ids it has already visited, or limiting the recursion depth.&lt;/p&gt;
&lt;p&gt;So while Nostr is a good fit for simple feeds, more complex ones can run into trouble: very greedy content loading and, as a result, a very large number of requests and poor performance.&lt;/p&gt;
&lt;p&gt;As for the features we are used to in top social networks — a smart feed and many flexible filters — it seems you cannot build them with Nostr alone, without custom add-ons and without losing decentralization.&lt;/p&gt;
&lt;p&gt;Then again, maybe it is better without a smart feed: it is not great that you often cannot find a post you liked a second time.&lt;/p&gt;
&lt;h4 id=&quot;community&quot;&gt;Community&lt;/h4&gt;
&lt;p&gt;One of the features built on Nostr that I had to work with is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/72.md&quot;&gt;NIP-72&lt;/a&gt;.&lt;br&gt;
In short, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/72.md&quot;&gt;NIP-72&lt;/a&gt; is a NIP that describes moderated topic communities on top of normal Nostr events. A community is defined by an addressable event (&lt;code&gt;kind:34550&lt;/code&gt;), which lists its moderators. Any user can publish a post that links to this community — that is, propose a publication in the community. But for a post to actually count as part of the community and show up in its feed, one of the moderators must publish a separate approval event (&lt;code&gt;kind:4550&lt;/code&gt;). This event contains the original post in full and is signed by the moderator's key. When building the community feed, clients rely on these approval events, not on the posts with the tag. So selection and moderation here are fully on the side of public, verifiable signatures.&lt;/p&gt;
&lt;p&gt;In principle, this works. But when a manager comes and says that any moderator should be able to moderate the community without the creator's approval, a problem appears. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/72.md&quot;&gt;NIP-72&lt;/a&gt; does not assume that someone from the moderator list can edit the community definition itself. The moderator list in &lt;code&gt;kind:34550&lt;/code&gt; is just &lt;code&gt;p&lt;/code&gt; tags listed inside an event signed by its owner. Only the holder of the private key that originally signed it can change this event (and therefore the moderator list) — that is, the community creator, not any of the listed moderators. This is a direct result of a base Nostr limit: only the holder of the key that is authoritative for an event's state can edit or redefine that state, and the protocol does not split this authority into roles at all. Trying to solve this with Nostr alone, without going beyond the protocol, leads nowhere good in the end. In practice the problem was solved with a central add-on — an extra service that adds extra signatures to events on top of the protocol model. But even with this solution I was very unhappy both with the &lt;em&gt;communities&lt;/em&gt; feature itself and with the final implementation.&lt;/p&gt;
&lt;h4 id=&quot;one-more-similar-use-case-organizations&quot;&gt;One more similar use case — organizations&lt;/h4&gt;
&lt;p&gt;I had to deal with this case too. Here is a short description of the task. There are organizations and sub-organizations, and posts and other similar events are published on their behalf. Only members can publish on behalf of an organization or sub-organization. Members have roles, and depending on the role they can invite other members.&lt;/p&gt;
&lt;p&gt;Building this with Nostr alone is a doubtful idea. The protocol has no built-in model for roles, for an &quot;organization → sub-organization&quot; hierarchy, or for invitations; all it has is signing with one key as the only way to authorize. Here it is worth mentioning that Nostr did have a &quot;native&quot; way to delegate signing — &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nostr-protocol/nips/blob/master/26.md&quot;&gt;NIP-26 (Delegated Event Signing)&lt;/a&gt;, but it never caught on and is now marked as deprecated. So even the protocol's own delegation mechanism was, in the end, seen as a failure, and in practice the task is solved centrally — for example, with &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/erskingardner/keycast&quot;&gt;keycast&lt;/a&gt;, a service for delegating signing in Nostr, made specially for team work with keys. But even keycast does not fully solve the task, in particular the problem of roles. To give teams control over who can sign what, its authors had to build their own system of policies and permissions around keys — something the protocol does not have. This says a lot: even a tool made specially for delegating signing ends up coming down to writing a full backend from scratch. So managing roles and an organization hierarchy will also need central solutions — and not only for delegating signing, but also separately for managing roles. The amount of such work is comparable to writing a separate backend for this task, and using Nostr as the backend, or even as part of it, looks unjustified here.&lt;/p&gt;
&lt;h4 id=&quot;conclusions&quot;&gt;Conclusions&lt;/h4&gt;
&lt;p&gt;If you go through all these cases — from notes and one2one chats to groups, feeds, and organizations — a fairly clear pattern appears.&lt;/p&gt;
&lt;p&gt;Nostr really is a ready backend out of the box, and in two forms at once. It can be a fully decentralized solution based on a network of public relays. Or it can be a central one — if you run your own private relay and use the protocol just as a handy, ready format for data, sync, and authentication on top of your own infrastructure. The second option, by the way, is not as much of a compromise as it may seem: even if you give up decentralization, you still get a ready event model, signatures, encryption, and many ready client libraries. That is, you save time not only on infrastructure, but also on designing the communication protocol itself.&lt;/p&gt;
&lt;p&gt;And for a number of tasks this solution really fits well — notes, chats (especially one2one), simple post feeds without claims to a complex feed. Here Nostr closes the classic problems (sync, encryption, identity) with almost no need to write your own backend at all.&lt;/p&gt;
&lt;p&gt;But along the way I also collected a few lessons for myself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not try to build a full social network on the level of Facebook, or any top social network, on Nostr.&lt;/strong&gt; As soon as a smart feed, flexible filtering, role models with hierarchy, and other features long standard for top social networks come into play, the protocol starts to fall apart. Not because the NIP authors missed something, but because the model itself (a relay as a dumb store of signed events, with no server that has the full and sole right to complex business logic) is not made for this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not try to get around the protocol's limits.&lt;/strong&gt; The temptation to &quot;stretch&quot; Nostr to the business logic you need with custom add-ons — be it a synthetic moderator model, extra signatures on top of events, or third-party delegation services — is strong, and almost always technically possible. But, as the NIP-72 and organizations cases show, at some point these add-ons start to weigh more than the protocol they were meant to make simpler. And, more importantly, getting around the limits is not free. Besides losing decentralization and compatibility with the rest of the Nostr ecosystem (other clients, relays, NIPs), such add-ons are always one more surface for bugs — bugs in code that no one but you has tested at scale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before you start building, weigh both paths honestly: use Nostr with add-ons, or think about a separate backend for the specific task.&lt;/strong&gt; Your own backend for roles, hierarchy, and moderation is written without looking back at someone else's protocol limits like &quot;one key owner&quot;. It is often faster than building workarounds around Nostr — especially if the task fits the &quot;signed events + relay&quot; model poorly from the start.&lt;/p&gt;
&lt;p&gt;The bottom line: Nostr is a great tool when the task lands in its &quot;native&quot; niche — and surprisingly awkward when you try to stretch it onto something it was not meant for. As with any tool, the main thing is to understand in advance which of these two groups your task falls into, instead of finding out after a month of work.&lt;/p&gt;
&lt;h4 id=&quot;the-app-from-this-article&quot;&gt;The app from this article&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/AlexeyYuPopkov/nostr_notes&quot;&gt;Private Notes (Nostr)&lt;/a&gt; is open source — you can look through the code to see the approach in practice.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;A Russian version of this article is available on Habr: &lt;a rel=&quot;nofollow&quot; href=&quot;https://habr.com/ru/articles/1050348/&quot;&gt;habr.com/ru/articles/1050348&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22603/nostr-as-a-backend-out-of-the-box-where-it-fits-and-where-it-doesnt</guid>
<pubDate>Tue, 14 Jul 2026 10:46:03 +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>Fskin alpha version is Live</title>
<link>https://coderlegion.com/21354/fskin-alpha-version-is-live</link>
<description>&lt;p&gt;Every Flutter dev has been here.&lt;br&gt;
Design changed the primary color.&lt;br&gt;
You open the codebase.&lt;br&gt;
You update the hex.&lt;br&gt;
You build.&lt;br&gt;
You submit. &lt;br&gt;
You wait. a full day for a color.&lt;/p&gt;
&lt;p&gt;There’s a better solution now: Fskin&lt;/p&gt;
&lt;p&gt;I built flutter_skin. Publish from a dashboard and the App updates on realtime No build. No review. No restart.&lt;/p&gt;
&lt;p&gt;Alpha is live today.&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/21354/fskin-alpha-version-is-live</guid>
<pubDate>Thu, 25 Jun 2026 08:39:15 +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>I built a streaming app with 7,000+ downloads at ₹0/month — here's the exact architecture</title>
<link>https://coderlegion.com/19611/i-built-a-streaming-app-with-7-000-downloads-at-0-month-heres-the-exact-architecture</link>
<description>&lt;p&gt;I'm Wasey, a solo developer from Varanasi, India. I built DramaHub — &lt;br&gt;
a fully live OTT streaming app on Google Play Store. 7,000+ downloads, &lt;br&gt;
1,000+ daily active users, sub-1% crash rate, ₹0/month infrastructure.&lt;/p&gt;
&lt;p&gt;Here's the exact architecture that makes it possible.&lt;/p&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;
&lt;p&gt;I had zero budget for servers. A typical OTT backend needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A backend server (₹3,000–15,000/month)&lt;/li&gt;
&lt;li&gt;A database (₹1,500+/month)&lt;/li&gt;
&lt;li&gt;A CDN (₹500+/month)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I had ₹0. So I had to think differently.&lt;/p&gt;
&lt;h3 id=&quot;github-as-the-database&quot;&gt;GitHub as the Database&lt;/h3&gt;
&lt;p&gt;All drama content, episode metadata, and app config live as JSON files &lt;br&gt;
in a public GitHub repository. Free. Unlimited. Versioned automatically.&lt;/p&gt;
&lt;p&gt;Every drama looks like this in JSON:&lt;/p&gt;
&lt;p&gt;{&lt;br&gt;
  &quot;id&quot;: &quot;drama_001&quot;,&lt;br&gt;
  &quot;title&quot;: &quot;Drama Title&quot;,&lt;br&gt;
  &quot;thumbnail&quot;: &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cdn.jsdelivr.net/&quot;&gt;https://cdn.jsdelivr.net/&lt;/a&gt;...&quot;,&lt;br&gt;
  &quot;episodes&quot;: [&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;ep&quot;: 1,
  &quot;title&quot;: &quot;Episode 1&quot;,
  &quot;source&quot;: &quot;youtube_id_here&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  ]&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;The app fetches this JSON on launch, caches it locally, and serves &lt;br&gt;
content instantly on subsequent opens.&lt;/p&gt;
&lt;h3 id=&quot;cloudflare-workers-as-the-cdn-proxy&quot;&gt;Cloudflare Workers as the CDN Proxy&lt;/h3&gt;
&lt;p&gt;Hitting GitHub raw URLs directly has rate limits. So I built a &lt;br&gt;
Cloudflare Worker that sits in front of GitHub:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Caches all JSON responses at the edge globally&lt;/li&gt;
&lt;li&gt;Handles unlimited traffic&lt;/li&gt;
&lt;li&gt;Auto-invalidates when content version changes&lt;/li&gt;
&lt;li&gt;Cost: ₹0&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Architecture: Flutter app → Cloudflare Worker → GitHub raw JSON&lt;/p&gt;
&lt;h3 id=&quot;the-dual-player-system&quot;&gt;The Dual Player System&lt;/h3&gt;
&lt;p&gt;DramaHub runs two video players:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Primary — YouTube WebView player&lt;/strong&gt;&lt;br&gt;
Most content is sourced from YouTube. The app uses a WebView-based &lt;br&gt;
player with custom controls and a URL whitelist for security.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Secondary — Custom video player with Cloudflare R2&lt;/strong&gt;&lt;br&gt;
For self-hosted content, I use a custom player backed by &lt;br&gt;
Cloudflare R2 storage.&lt;/p&gt;
&lt;p&gt;Both players are switchable instantly from the admin panel via &lt;br&gt;
remote JSON config. No APK update needed — ever.&lt;/p&gt;
&lt;h3 id=&quot;the-admin-panel&quot;&gt;The Admin Panel&lt;/h3&gt;
&lt;p&gt;This is the most important part of the architecture.&lt;/p&gt;
&lt;p&gt;The admin panel is a Flutter Web app that communicates directly &lt;br&gt;
with the GitHub REST API using Base64 encoding and SHA-based &lt;br&gt;
conflict detection. From the admin panel I can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add/edit/delete dramas and episodes&lt;/li&gt;
&lt;li&gt;Toggle ads per screen with cooldown timers&lt;/li&gt;
&lt;li&gt;Switch between YouTube and custom player instantly&lt;/li&gt;
&lt;li&gt;Change CDN URLs without any release&lt;/li&gt;
&lt;li&gt;Force update all users&lt;/li&gt;
&lt;li&gt;Control app config globally&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything is remote. Everything is instant. No app store review cycle.&lt;/p&gt;
&lt;h3 id=&quot;the-crash-that-taught-me-everything&quot;&gt;The Crash That Taught Me Everything&lt;/h3&gt;
&lt;p&gt;One day the app went completely down. Every user got a crash on launch.&lt;/p&gt;
&lt;p&gt;The reason: my Cloudflare Worker wasn't caching properly. Every request &lt;br&gt;
was hitting GitHub directly and I burned through the API rate limit &lt;br&gt;
in minutes.&lt;/p&gt;
&lt;p&gt;I fixed the Worker caching. Then I built a full backup system:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Direct GitHub raw URL as fallback&lt;/li&gt;
&lt;li&gt;Backup Worker as secondary fallback&lt;/li&gt;
&lt;li&gt;Switchable from admin panel in under 60 seconds&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No APK release needed. I built that entire system in one night.&lt;/p&gt;
&lt;h3 id=&quot;firebase-integration&quot;&gt;Firebase Integration&lt;/h3&gt;
&lt;p&gt;Even with zero backend cost, I use Firebase for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anonymous Auth&lt;/li&gt;
&lt;li&gt;FCM push notifications with deep links&lt;/li&gt;
&lt;li&gt;Crashlytics for real-time crash monitoring&lt;/li&gt;
&lt;li&gt;Analytics with 7 custom tracked events&lt;/li&gt;
&lt;li&gt;Atomic view counter increments via Firestore REST API&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;results-after-18-months&quot;&gt;Results After 18 Months&lt;/h3&gt;
&lt;p&gt; 7,000+ downloads&lt;br&gt;
 1,000+ daily active users&lt;br&gt;
 Sub-1% crash rate&lt;br&gt;
 ₹0/month infrastructure cost&lt;br&gt;
 Live on Google Play Store&lt;/p&gt;
&lt;h3 id=&quot;key-lesson&quot;&gt;Key Lesson&lt;/h3&gt;
&lt;p&gt;Build your admin panel first. Remote config is not a feature — &lt;br&gt;
it's survival. The ability to change anything without a new release &lt;br&gt;
has saved me dozens of times.&lt;/p&gt;
&lt;p&gt;GitHub as a database works at production scale if you architect &lt;br&gt;
the caching layer correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;GitHub: github.com/waseyjamal/dramahub&lt;br&gt;
Play Store: &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.dramahub.drama_hub&quot;&gt;https://play.google.com/store/apps/details?id=com.dramahub.drama_hub&lt;/a&gt;&lt;br&gt;
Twitter: @waseybuilds&lt;/p&gt;
&lt;p&gt;Happy to answer any questions about the architecture.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19611/i-built-a-streaming-app-with-7-000-downloads-at-0-month-heres-the-exact-architecture</guid>
<pubDate>Wed, 03 Jun 2026 20:39:43 +0000</pubDate>
</item>
<item>
<title>I Built an AI-Powered Agricultural Logistics Platform for Farmers Worldwide — Here's How</title>
<link>https://coderlegion.com/19388/i-built-an-ai-powered-agricultural-logistics-platform-for-farmers-worldwide-heres-how</link>
<description>&lt;p&gt;16% of agricultural produce globally is lost every year before it reaches the buyer. Not because the crops are bad. Because the supply chain is broken.&lt;br&gt;
Farmers have no digital way to list surplus crops. Buyers can't discover what's available nearby. Drivers get no coordination layer. Everyone loses.&lt;br&gt;
I built Micro-Harvest to fix all three — at the same time — using a single AI conversation.&lt;/p&gt;
&lt;p&gt;What Is Micro-Harvest?&lt;br&gt;
Micro-Harvest is a three-sided agricultural logistics platform built for the Google Cloud Rapid Agent Hackathon 2026 (Elastic Track). It connects:&lt;/p&gt;
&lt;p&gt; Farmers (Growers) — who list surplus crops via an AI chat agent&lt;br&gt;
 Buyers (Producers) — who discover listings through smart geo-search&lt;br&gt;
 Drivers (Transporters) — who get auto-assigned hauls and handle delivery&lt;/p&gt;
&lt;p&gt;One conversation from the farmer triggers the entire chain — AI reasoning, buyer matching, driver dispatch, and Stripe payment settlement. All automated.&lt;br&gt;
 Watch the full demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/0ZdplkglDBo&quot;&gt;https://youtu.be/0ZdplkglDBo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The Core Idea: One Message Does Everything&lt;br&gt;
Here's what happens when a farmer speaks or types:&lt;/p&gt;
&lt;p&gt;&quot;I have 20 macro bins of Merlot ready to go today, asking $4000 per ton&quot;&lt;/p&gt;
&lt;p&gt;Under the hood, in under 30 seconds:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Gemini 3.1 Flash extracts → crop type, quantity, price, urgency&lt;/li&gt;
&lt;li&gt;AI Reasoning Engine scores → urgency level, weather risk, vehicle recommendation&lt;/li&gt;
&lt;li&gt;Elastic MCP (24 tools) → geo-matches nearest available buyer + transporter&lt;/li&gt;
&lt;li&gt;FCM push notification → driver gets haul assignment&lt;/li&gt;
&lt;li&gt;Driver completes → Gate 1 pickup + Gate 2 delivery confirmation&lt;/li&gt;
&lt;li&gt;Stripe → 80% to farmer, 15% to driver, 5% platform fee. Same day.&lt;br&gt;
No phone calls. No middlemen. No 3-day delays.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Tech Stack-&lt;br&gt;
AI Agent - Google Agent Platform, LLM - Gemini 3.1 Flash Lite Preview, Search - Elastic MCP — 24 tools, Backend - Firebase (Firestore, Auth, Cloud Functions) Payments - Stripe, Mobile - Flutter (3 Android apps) Admin - Flutter Web&lt;/p&gt;
&lt;p&gt;The Elastic MCP server is the heart of the matching layer. It exposes 24 tools covering semantic crop search, geo-distance queries, transporter availability matching, index health monitoring, and BigQuery analytics. Every search the buyer runs fires a real geo-distance query against the listings index in real time — you can watch it in the Elastic search profiler.&lt;/p&gt;
&lt;p&gt;The Feature I'm Most Proud Of: Offline-First Listing&lt;br&gt;
Farmers often operate in areas with poor connectivity. Someone out in a field with no signal shouldn't lose their listing.&lt;br&gt;
So I built the Harvest Agent to be offline-first:&lt;/p&gt;
&lt;p&gt;Farmer speaks or types their crop details — no internet detected&lt;br&gt;
App shows an offline badge and queues the message locally&lt;br&gt;
The moment connectivity returns — listing syncs and goes live automatically&lt;br&gt;
Zero farmer action needed&lt;/p&gt;
&lt;p&gt;This was the hardest part to get right. The local queue needed to survive app restarts, handle partial sync failures, and not create duplicate listings on reconnect. It took more debugging than the entire AI integration.&lt;/p&gt;
&lt;p&gt;Three Apps, One Platform&lt;br&gt;
All three apps are built in Flutter and share the same Firebase project:&lt;br&gt;
Farmer App (Grower)&lt;/p&gt;
&lt;p&gt;Voice + text AI listing creation&lt;br&gt;
Offline message queue with auto-sync&lt;br&gt;
Real-time listing status tracking&lt;br&gt;
Earnings dashboard&lt;/p&gt;
&lt;p&gt;Buyer App (Producer)&lt;/p&gt;
&lt;p&gt;Natural language surplus search&lt;br&gt;
Geo-filtered results with radius control&lt;br&gt;
Claim and reserve listings&lt;br&gt;
Live transport tracking&lt;/p&gt;
&lt;p&gt;Driver App (Transporter)&lt;/p&gt;
&lt;p&gt;FCM haul notifications&lt;br&gt;
GPS route guidance to farm&lt;br&gt;
Gate 1 + Gate 2 photo capture&lt;br&gt;
Instant payment on delivery confirmation&lt;/p&gt;
&lt;p&gt;You can download and install all three APKs from the GitHub release and walk through the complete end-to-end flow yourself. APK links are in the Links section at the bottom.&lt;br&gt;
(Enable &quot;Install from unknown sources&quot; in Android settings first)&lt;/p&gt;
&lt;p&gt;What I'd Build Next&lt;br&gt;
A few things I want to add after the hackathon:&lt;/p&gt;
&lt;p&gt;Price intelligence — Gemini currently recommends a price band but doesn't pull live commodity market rates; integrating real-time agri pricing APIs would make this genuinely powerful&lt;br&gt;
iOS builds — currently Android only; the Flutter code is cross-platform but Apple provisioning didn't make the deadline&lt;br&gt;
Multi-language AI agent — supporting regional languages so the Harvest Agent works natively for farmers regardless of where they are in the world&lt;/p&gt;
&lt;p&gt;Links&lt;br&gt;
 Demo Video&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/0ZdplkglDBo&quot;&gt;https://youtu.be/0ZdplkglDBo&lt;/a&gt;&lt;br&gt;
 Landing Page&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://micro-harvest-landing.web.app&quot;&gt;https://micro-harvest-landing.web.app&lt;/a&gt;&lt;br&gt;
️ Admin Panel&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://micro-harvest.web.app&quot;&gt;https://micro-harvest.web.app&lt;/a&gt;&lt;br&gt;
 GitHub&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/micro-harvest&quot;&gt;https://github.com/ssurekumar01111-hue/micro-harvest&lt;/a&gt;&lt;br&gt;
 Farmer APK&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/grower-release.apk&quot;&gt;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/grower-release.apk&lt;/a&gt;&lt;br&gt;
 Buyer APK&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/producer-release.apk&quot;&gt;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/producer-release.apk&lt;/a&gt;&lt;br&gt;
 Driver APK&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/transporter-release.apk&quot;&gt;https://github.com/ssurekumar01111-hue/micro-harvest/releases/download/v1.0.0/transporter-release.apk&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Building this solo in Flutter + Firebase was genuinely fun — the Google Agent Platform + Elastic MCP combination is seriously powerful for this kind of multi-party matching problem. Happy to answer questions about any part of the architecture in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19388/i-built-an-ai-powered-agricultural-logistics-platform-for-farmers-worldwide-heres-how</guid>
<pubDate>Mon, 01 Jun 2026 06:04:51 +0000</pubDate>
</item>
<item>
<title>[Dev Feedback]  APEX F1 – full F1 race sim in Flutter (GPS circuits, AI rivals, multi‑season)</title>
<link>https://coderlegion.com/19183/dev-feedback-apex-f1-full-f1-race-sim-in-flutter-gps-circuits-ai-rivals-multi-season</link>
<description>&lt;p&gt;Hey devs,&lt;/p&gt;
&lt;p&gt;I’m a 6th‑semester Software Engineering student and I’ve been building a passion project called APEX F1 – a full Formula 1 race simulation app written entirely in Flutter &amp;amp; Dart. ️&lt;/p&gt;
&lt;p&gt;It started as a simple F1 calendar and turned into a full race weekend simulator with AI, GPS‑accurate tracks, and a persistent championship mode. I’m pretty happy with how far it’s come, but now I’d really like feedback from more experienced developers.&lt;/p&gt;
&lt;p&gt;High‑level overview&lt;br&gt;
Built with Flutter 3 + Dart 3, zero backend – all logic runs on‑device.&lt;/p&gt;
&lt;p&gt;Full F1 race weekend: qualifying (Q1/Q2/Q3) + race simulation.&lt;/p&gt;
&lt;p&gt;GPS‑accurate circuit maps for all 24 tracks in the 2024 F1 calendar (using my own open‑sourced GeoJSON data).&lt;/p&gt;
&lt;p&gt;20 AI drivers with different consistency / behavior profiles.&lt;/p&gt;
&lt;p&gt;Three seasons built‑in (2023, 2024, 2025) + support for custom seasons via JSON.&lt;/p&gt;
&lt;p&gt;Technical details&lt;br&gt;
Architecture: feature‑first, “clean‑ish” structure (feature modules with shared core).&lt;/p&gt;
&lt;p&gt;State: mostly StatefulWidget + AnimationController (no Riverpod/BLoC yet).&lt;/p&gt;
&lt;p&gt;Storage: shared_preferences for profile, championship progress, and custom seasons.&lt;/p&gt;
&lt;p&gt;UI: Neon dark theme, Google Fonts (Orbitron + Rajdhani), ~20+ animated transitions.&lt;/p&gt;
&lt;p&gt;Responsive layout: custom helper that scales sizes based on a 390px baseline, tested from 360px phones to 600px+ tablets.&lt;/p&gt;
&lt;p&gt;Links&lt;br&gt;
Web build (Firebase Hosting): &lt;a rel=&quot;nofollow&quot; href=&quot;https://apex-f1-159.web.app&quot;&gt;https://apex-f1-159.web.app&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/GulrezQayyum/apex-f1&quot;&gt;https://github.com/GulrezQayyum/apex-f1&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Circuit data (open source): &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/GulrezQayyum/f1_circuits-2024_season&quot;&gt;https://github.com/GulrezQayyum/f1_circuits-2024_season&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;What I’d love feedback on (for devs)&lt;br&gt;
If you have some time to check the repo or play a couple of races, I’d really appreciate feedback on:&lt;/p&gt;
&lt;p&gt;Architecture &amp;amp; structure&lt;/p&gt;
&lt;p&gt;Is the feature‑first layout reasonable for a project this size?&lt;/p&gt;
&lt;p&gt;Any obvious refactors you’d suggest before it grows further (e.g., extracting domain layer, introducing DI, etc.)?&lt;/p&gt;
&lt;p&gt;State management &amp;amp; reactivity&lt;/p&gt;
&lt;p&gt;Right now it’s mostly StatefulWidget + manual setState and controllers.&lt;/p&gt;
&lt;p&gt;Would you recommend migrating to Riverpod / BLoC / ValueNotifiers for this kind of simulation UI?&lt;/p&gt;
&lt;p&gt;Performance&lt;/p&gt;
&lt;p&gt;The live GPS circuit map animates 20 cars every frame. Any suggestions on optimizing this in Flutter (custom painters, offscreen layers, etc.)?&lt;/p&gt;
&lt;p&gt;Things you’d profile or watch out for on mid/low‑end devices?&lt;/p&gt;
&lt;p&gt;Data &amp;amp; simulation design&lt;/p&gt;
&lt;p&gt;Lap time / AI behavior is currently driven by JSON + some randomness around base pace and consistency.&lt;/p&gt;
&lt;p&gt;If you’ve built sims before, any tips for keeping it fun but not totally random?&lt;/p&gt;
&lt;p&gt;Feel free to be brutal (but constructive) — I want to use this project to level up as a developer.&lt;/p&gt;
&lt;p&gt;If anyone’s interested, I can also break down the GPS data pipeline, circuit rendering, or how I structured the race engine in more detail. Happy to share code snippets or diagrams.&lt;/p&gt;
&lt;p&gt;Thanks in advance to anyone who takes a look. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19183/dev-feedback-apex-f1-full-f1-race-sim-in-flutter-gps-circuits-ai-rivals-multi-season</guid>
<pubDate>Thu, 28 May 2026 20:17:01 +0000</pubDate>
</item>
<item>
<title>I built a free AI writing SaaS with Flutter Web + Gemini —  would love feedback from this community</title>
<link>https://coderlegion.com/18140/built-free-ai-writing-saas-with-flutter-web-gemini-would-love-feedback-from-this-community</link>
<description>&lt;p&gt;Hey CoderLegion &lt;/p&gt;
&lt;p&gt;I've been a Flutter/Firebase developer for the past &lt;br&gt;
couple of years, and I recently shipped something I'm genuinely &lt;br&gt;
proud of — ScribeAI, a free AI-powered writing platform.&lt;/p&gt;
&lt;p&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://scribeai-f7645.web.app&quot;&gt;https://scribeai-f7645.web.app&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's what it does — 8 tools in one platform:&lt;/p&gt;
&lt;p&gt; ATS Resume Scorer — paste your resume + job description, &lt;br&gt;
get an instant compatibility score, missing keywords, and &lt;br&gt;
improvement suggestions&lt;/p&gt;
&lt;p&gt;✉️ Cover Letter Generator — tailored cover letters matched &lt;br&gt;
to any job description&lt;/p&gt;
&lt;p&gt; Document Summarizer — turn long contracts and reports into &lt;br&gt;
clean structured summaries&lt;/p&gt;
&lt;p&gt; Blog &amp;amp; SEO Writer — full SEO-optimized blog posts from &lt;br&gt;
just a topic and keywords&lt;/p&gt;
&lt;p&gt;✏️ Grammar &amp;amp; Tone Fixer — fix errors and adjust tone &lt;br&gt;
(professional, casual, formal)&lt;/p&gt;
&lt;p&gt; Paraphrasing Tool — rewrite text in multiple styles &lt;br&gt;
and variations&lt;/p&gt;
&lt;p&gt; LinkedIn Bio Writer — optimized About section that &lt;br&gt;
attracts recruiters&lt;/p&gt;
&lt;p&gt; Interview Q&amp;amp;A Generator — role-specific questions and &lt;br&gt;
model answers&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;️ Tech stack for those curious:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flutter Web (single codebase, runs in any browser)&lt;/li&gt;
&lt;li&gt;Google Gemini AI API for all 8 tools&lt;/li&gt;
&lt;li&gt;Firebase Auth + Firestore for backend&lt;/li&gt;
&lt;li&gt;BLoC state management&lt;/li&gt;
&lt;li&gt;Razorpay for payments&lt;/li&gt;
&lt;li&gt;Deployed on Firebase Hosting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Monetization: Free tier (3 uses/day) → Credits (₹99 = 20) &lt;br&gt;
→ Pro (₹199/month unlimited)&lt;/p&gt;
&lt;p&gt;Other features: PDF download, copy to clipboard, share &lt;br&gt;
results as public link, usage history, (signup needed to try)&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This started as a side project to prove that a Indie-developer can build and ship a real SaaS product without &lt;br&gt;
a team or funding.&lt;/p&gt;
&lt;p&gt;I'd genuinely love feedback from this community on:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is the UI/UX intuitive? Anything confusing?&lt;/li&gt;
&lt;li&gt;Which tool did you find most useful?&lt;/li&gt;
&lt;li&gt;What feature would make you actually use this regularly?&lt;/li&gt;
&lt;li&gt;Any bugs you spotted?&lt;/li&gt;
&lt;li&gt;Would you pay for the Pro plan? If not, why?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Be as brutal as you want — I can handle it and I'll &lt;br&gt;
actually fix things based on your feedback &lt;/p&gt;
&lt;p&gt;Thanks for checking it out!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18140/built-free-ai-writing-saas-with-flutter-web-gemini-would-love-feedback-from-this-community</guid>
<pubDate>Sun, 17 May 2026 07:18:24 +0000</pubDate>
</item>
<item>
<title>What's the most annoying problem in your daily life that doesn't have a good mobile app yet?</title>
<link>https://coderlegion.com/17845/whats-the-most-annoying-problem-in-your-daily-life-that-doesnt-have-a-good-mobile-app-yet</link>
<description>&lt;p&gt;Hey CoderLegion community &lt;br&gt;
I'm a Flutter &amp;amp; Firebase developer currently looking for my next build idea — and instead of guessing, I want to ask the people who actually feel the pain.&lt;br&gt;
What's a real problem you face regularly that you wish had a better mobile app solution?&lt;br&gt;
Could be anything:&lt;/p&gt;
&lt;p&gt;Something at work that still runs on WhatsApp groups or spreadsheets&lt;br&gt;
A local service that has no app (booking, delivery, scheduling)&lt;br&gt;
A tool you use daily that feels clunky or outdated&lt;br&gt;
Something you've thought &quot;why doesn't this app exist yet?&quot;&lt;/p&gt;
&lt;p&gt;I build full-stack mobile apps using Flutter + Firebase — so I'm looking for ideas that involve real-time data, multi-user roles, bookings, payments, or location tracking. But drop anything — I want to hear what actually frustrates people.&lt;br&gt;
Drop your pain point in the comments  Even half-formed ideas are welcome. If enough people share the same problem, that's a signal worth building for.&lt;br&gt;
Let's see what's missing out there.&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/17845/whats-the-most-annoying-problem-in-your-daily-life-that-doesnt-have-a-good-mobile-app-yet</guid>
<pubDate>Thu, 14 May 2026 13:33:13 +0000</pubDate>
</item>
<item>
<title>Cross-Platform Music App Development Using Flutter and React Native</title>
<link>https://coderlegion.com/17799/cross-platform-music-app-development-using-flutter-and-react-native</link>
<description>&lt;h2 id=&quot;quick-overview&quot;&gt;Quick Overview&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Flutter (Dart) and React Native (JavaScript) are the top frameworks&lt;br&gt;
for launching music apps on iOS and Android from a single codebase.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Both support essential music features, such as streaming, background &lt;br&gt;
playback, waveform visualization, and media controls, through &lt;br&gt;
well-maintained plugin ecosystems.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Audio performance, low latency, and real-time processing are the&lt;br&gt;
main technical challenges in cross-platform music development.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The right framework depends on your team's skills, the app's&lt;br&gt;
complexity, and performance requirements; there is no&lt;br&gt;
one-size-fits-all solution.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choosing a cross-platform approach can reduce development time and&lt;br&gt;
costs by 30 to 50 percent compared to building separate native iOS&lt;br&gt;
and Android apps.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You have a great idea for a music app. It could be a tool for collaborative playlists, a practice companion for musicians, or a complete streaming experience. You've done your research and have a development plan. Then reality hits: do you build it for iOS first, Android, or both at the same time? If you choose both, does that mean you need two separate teams, two separate codebases, and twice the budget? &lt;/p&gt;
&lt;p&gt;This is the exact problem that has led developers to cross-platform frameworks. Right now, Flutter and React Native are the two main players in this field. This article explains how each framework approaches music app development, the trade-offs involved, and how to make the best choice for your project.&lt;/p&gt;
&lt;h2 id=&quot;why-cross-platform-is-a-smart-choice-for-music-apps&quot;&gt;Why Cross-Platform Is a Smart Choice for Music Apps&lt;/h2&gt;
&lt;p&gt;Before exploring frameworks, note that cross-platform development offers benefits for complex music apps involving real-time audio, background playback, hardware controls, Bluetooth switching, and low-latency features. While native development historically excels here, the gap with cross-platform has narrowed. &lt;/p&gt;
&lt;p&gt;Platforms like Flutter and React Native have developed to the point where most music app experiences feel just like native ones in everyday use. And for teams working with &lt;a rel=&quot;nofollow&quot; href=&quot;https://wpwebinfotech.com/music/&quot;&gt;music app development services&lt;/a&gt;, the ability to ship one codebase to two platforms without sacrificing quality is a genuine competitive advantage.&lt;/p&gt;
&lt;p&gt;The economics are clear: one cross-platform team costs less than two native teams, with streamlined maintenance and simultaneous feature releases. For startups and mid-sized companies, this advantage can determine a project's viability. &lt;/p&gt;
&lt;h2 id=&quot;flutter-in-music-app-development-essential-insights&quot;&gt;Flutter in Music App Development: Essential Insights&lt;/h2&gt;
&lt;p&gt;Flutter, supported by Google, uses Dart and the Skia rendering engine, now replaced by Impeller. Instead of relying on the host platform's UI components, Flutter renders every pixel itself. This approach benefits music apps.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Visual consistency:&lt;/strong&gt; Waveform displays, equalizer animations, album&lt;br&gt;
art carousels, and custom playback controls look the same on iOS and&lt;br&gt;
Android, eliminating UI rendering frustration across platforms.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audio handling:&lt;/strong&gt; Since Flutter lacks a built-in audio engine, you'll&lt;br&gt;
need packages like just_audio, audio_service, and flutter_sound.&lt;br&gt;
just_audio supports streaming, gapless playback, pitch shifting, and&lt;br&gt;
audio effects, suitable for most music apps. audio_service manages&lt;br&gt;
background playback and media notifications, essential for any music&lt;br&gt;
app.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt; Flutter's compiled Dart code and rendering pipeline&lt;br&gt;
ensure smooth animations and a responsive UI, but can lag during&lt;br&gt;
heavy platform-channel communication. Handling real-time native audio&lt;br&gt;
processing may cause delays due to Dart-native exchange. &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ideal use cases:&lt;/strong&gt; They include streaming, music discovery, podcast,&lt;br&gt;
music education, and DJ mixing apps that don't need very low-latency&lt;br&gt;
audio (under 10 ms).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;react-native-for-music-app-development-essential-insights&quot;&gt;React Native for Music App Development: Essential Insights&lt;/h2&gt;
&lt;p&gt;React Native, maintained by Meta, uses JavaScript or TypeScript and connects to native components. The newer &quot;New Architecture&quot; adopts JSI instead of the old bridge, significantly narrowing the performance gap with native development.  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The JavaScript ecosystem:&lt;/strong&gt; If your team already works with React or&lt;br&gt;
JavaScript, the learning curve is much shorter. For &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.cnvrtool.com/blog/react-native-app-development/&quot;&gt;React Native app&lt;br&gt;
development&lt;/a&gt;, the large npm ecosystem means there are libraries for&lt;br&gt;
almost everything a music app might need, from audio processing to&lt;br&gt;
music-theory tools.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audio libraries:&lt;/strong&gt; react-native-track-player is the preferred tool for&lt;br&gt;
streaming and queue management, supporting background playback, lock&lt;br&gt;
screen controls, and CarPlay/Android Auto. react-native-sound is good&lt;br&gt;
for basic audio, while react-native-audio-toolkit manages recording.&lt;br&gt;
For advanced DSP, native modules are used.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consistency:&lt;/strong&gt; React Native uses native components, so your app adopts&lt;br&gt;
each platform's look and feel. This works well for standard UI&lt;br&gt;
patterns but can be inconsistent for custom designs.   &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ideal use cases:&lt;/strong&gt; It includes music players, streaming services,&lt;br&gt;
social    platforms, learning apps, and apps where React or&lt;br&gt;
JavaScript skills    are important.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;flutter-vs-react-native-for-music-apps&quot;&gt;Flutter vs. React Native for Music Apps&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14613520242586826734&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=16029373426471469384&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;key-technical-considerations-specific-to-music-apps&quot;&gt;Key Technical Considerations Specific to Music Apps&lt;/h2&gt;
&lt;p&gt;Regardless of which framework you choose, certain challenges are common in cross-platform music development.  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Background audio playback:&lt;/strong&gt; Both frameworks handle this with dedicated&lt;br&gt;
packages: audio_service for Flutter and react-native-track-player for&lt;br&gt;
React Native, but require careful configuration of platform-specific&lt;br&gt;
entitlements and permissions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Low-latency audio:&lt;/strong&gt; For apps requiring latency below ~20ms, such as&lt;br&gt;
real-time tuners or live tools, cross-platform frameworks may reach&lt;br&gt;
their limits, and custom native modules might be necessary regardless&lt;br&gt;
of the framework.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audio streaming and buffering:&lt;/strong&gt; Managing an app's state is crucial.&lt;br&gt;
Music streaming apps must handle network issues, adaptive bitrate,&lt;br&gt;
and pre-buffering. Both frameworks support this, but implementation&lt;br&gt;
is complex.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Waveform visualization:&lt;/strong&gt; It uses significant CPU resources. &lt;a rel=&quot;nofollow&quot; href=&quot;https://verygood.ventures/blog/mastering-custompainter-in-flutter-from-svgs-to-racetracks/&quot;&gt;Flutter&lt;br&gt;
CustomPainter&lt;/a&gt; and React Native's canvas options, via&lt;br&gt;
react-native-skia or WebGL, handle this. Optimization is vital,&lt;br&gt;
especially on mid-range Android devices.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Choosing between Flutter and React Native isn't about picking the better tool but the right one for your team, timeline, and features. Flutter offers visual consistency and performance predictability, while React Native excels in ecosystem familiarity and native platform integration. Both can produce high-quality cross-platform music apps indistinguishable from native ones.&lt;/p&gt;
&lt;p&gt;Understanding your app's technical needs early is crucial. If real-time audio is essential, evaluate how frameworks handle native modules. For streaming or discovery, either framework works, and cross-platform saves are substantial. The best framework is the one your team confidently uses. Choose it, build effectively, and let the music speak.&lt;/p&gt;
&lt;h2 id=&quot;faqs-1&quot;&gt;FAQs&lt;/h2&gt;
&lt;h3 id=&quot;1-can-flutter-or-react-native-handle-real-time-audio-processing-for-music-apps-&quot;&gt;1. Can Flutter or React Native handle real-time audio processing for music apps?&lt;/h3&gt;
&lt;p&gt;Both handle standard audio tasks well with plugins, but lack out-of-the-box ultra-low-latency (under 10ms) support. Features like live tuning or MIDI require native code in Swift or Kotlin. Still, both frameworks can stream and play without native tweaks.  &lt;/p&gt;
&lt;h3 id=&quot;2-which-is-better-for-a-music-streaming-app-flutter-or-react-native-&quot;&gt;2. Which is better for a music streaming app, Flutter or React Native?&lt;/h3&gt;
&lt;p&gt;Both work well. Flutter provides consistent UI and smooth animations, while React Native's react-native-track-player offers a stronger streaming ecosystem with CarPlay and Android Auto support. React Native teams ship faster; Flutter users may prioritize visual appeal.  &lt;/p&gt;
&lt;h3 id=&quot;3-how-do-cross-platform-music-apps-handle-background-playback-&quot;&gt;3. How do cross-platform music apps handle background playback?&lt;/h3&gt;
&lt;p&gt;Both frameworks handle background audio, lock screen controls, and media notifications via dedicated packages: audio_service in Flutter and react-native-track-player in React Native. You must manually configure platform permissions and test on real devices, as emulators often poorly handle background audio.  &lt;/p&gt;
&lt;h3 id=&quot;4-is-flutter-or-react-native-faster-to-develop-a-music-app-with-&quot;&gt;4. Is Flutter or React Native faster to develop a music app with?&lt;/h3&gt;
&lt;p&gt;Your team's expertise influences the choice. JavaScript developers may prefer React Native for speed, while Flutter's clear structure eases Dart's learning curve and scalability. Both frameworks can cut development time by 30-50% versus native iOS and Android.  &lt;/p&gt;
&lt;h3 id=&quot;5-what-are-the-main-limitations-of-cross-platform-frameworks-for-music-apps-&quot;&gt;5. What are the main limitations of cross-platform frameworks for music apps?&lt;/h3&gt;
&lt;p&gt;Main limitations include low-latency audio, hardware integration, and delayed access to new APIs. Complex audio features often need native modules. But for most consumer music apps like streaming, playback, and visualization, these issues are generally not significant.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17799/cross-platform-music-app-development-using-flutter-and-react-native</guid>
<pubDate>Thu, 14 May 2026 07:43:33 +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>Building a Zero-Cost AI Feature in Flutter with Gemma 4 + Firebase</title>
<link>https://coderlegion.com/17447/building-a-zero-cost-ai-feature-in-flutter-with-gemma-4-firebase</link>
<description>&lt;p&gt;How to combine on-device inference with cloud sync — without paying a cent in API fees&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bworo35ns7cm6xwfa0wm.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-unspoken-reality-of-ai-api-costs&quot;&gt;The Unspoken Reality of AI API Costs&lt;/h3&gt;
&lt;p&gt;Here’s the moment every indie developer dreads. You’ve shipped your AI feature. Users love it. It’s working. Then you open your billing dashboard.&lt;/p&gt;
&lt;p&gt;Every question your users ask costs you money. Every summary generated, every classification run, you’re paying for it. You have created a successful product, but its popularity is now draining your resources.&lt;/p&gt;
&lt;p&gt;What if there was a way to run powerful AI in your Flutter app with zero ongoing API costs? No per-request charges. No server bills. No privacy risk from data leaving the device.&lt;/p&gt;
&lt;p&gt;That’s exactly what the combination of Gemma 4 and Firebase unlocks. Gemma does the thinking, entirely on the user’s phone. Firebase handles persistence and sync. The result is an AI-powered app that scales to thousands of users at near-zero marginal cost.&lt;/p&gt;
&lt;p&gt;I’m building my own product on this exact stack. Here’s how it works.&lt;/p&gt;
&lt;h3 id=&quot;why-cloud-ai-is-a-trap-for-indie-developers&quot;&gt;Why cloud AI is a trap for indie developers&lt;/h3&gt;
&lt;p&gt;Cloud AI APIs are seductive. You make one API call, get a response, ship the feature. But the cost model is brutal for bootstrapped products:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Costs scale directly with usage — success punishes you&lt;/li&gt;
&lt;li&gt;User data leaves the device — a privacy and trust problem&lt;/li&gt;
&lt;li&gt;No connection means broken features — poor offline experience&lt;/li&gt;
&lt;li&gt;You’re dependent on a third-party API that can change pricing or availability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On-device AI solves all four of these problems at once. The model runs locally, the user’s data never hits a network, it works offline, and you own the stack.&lt;/p&gt;
&lt;h3 id=&quot;the-stack-gemma-4-flutter-gemma-firebase&quot;&gt;The stack: Gemma 4 + flutter_gemma + Firebase&lt;/h3&gt;
&lt;p&gt;Three components, each with a clear job:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gemma 4 (the brain)&lt;/strong&gt;&lt;br&gt;
Gemma 4 is Google’s latest edge-optimized open model, built on the same research as Gemini but designed to run efficiently on mobile hardware. The E2B variant (Effective 2B parameters) runs in under 2GB of RAM thanks to 4-bit quantization. It’s practical for real devices, not just flagship phones. Critically, Gemma 4 supports native function calling, which means you can wire it directly into your app’s logic without any prompt engineering hacks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;flutter_gemma (the bridge)&lt;/strong&gt;&lt;br&gt;
The flutter_gemma package wraps the native LiteRT-LM inference engine in a clean Dart API. You get GPU acceleration, streaming responses, multimodal vision support, and function calling, all from Flutter. It supports Android, iOS, Web, and Desktop from a single codebase.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Firebase (the backbone)&lt;/strong&gt;&lt;br&gt;
Firebase handles everything that needs to live in the cloud: Firestore for storing and syncing AI outputs across devices, Firebase Auth for user identity, and optionally Firebase Storage if you need to persist larger assets. The key insight is that your AI inference never touches Firebase; only the results do.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-who-does-what&quot;&gt;The architecture: who does what&lt;/h3&gt;
&lt;p&gt;Here’s the principle that makes this work: AI never touches the network. Only the output does.&lt;/p&gt;
&lt;p&gt;The flow looks like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User creates content in the app (a note, a photo, a voice memo)&lt;/li&gt;
&lt;li&gt;Gemma processes it on-device — tagging, summarizing, classifying&lt;/li&gt;
&lt;li&gt;The AI output (not the raw input) is written to Firestore&lt;/li&gt;
&lt;li&gt;Firebase syncs the output to the user’s other devices&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The user’s raw thoughts, photos, or voice never leave their phone. The model never makes a network call. But their data is still available everywhere they need it, because Firebase syncs the processed result.&lt;/p&gt;
&lt;h3 id=&quot;building-it-a-private-ai-notes-app&quot;&gt;Building it: a private AI notes app&lt;/h3&gt;
&lt;p&gt;Let’s make this concrete. We’ll build a simple notes app where Gemma automatically tags and summarizes each note on-device, then Firebase syncs the result across the user’s devices. Here’s the step-by-step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Set up flutter_gemma&lt;/strong&gt;&lt;br&gt;
Add the dependency to your pubspec.yaml:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;dependencies:  flutter_gemma: ^0.13.1  cloud_firestore: ^5.0.0  firebase_auth: ^5.0.0&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Download the Gemma 4 E2B model from Hugging Face (you’ll need to accept the license terms). Store your HuggingFace token securely; never hardcode it. On first launch, the app downloads the model to local storage. This is a one-time operation of around 1.5GB.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Run on-device inference&lt;/strong&gt;&lt;br&gt;
Initialize the model and run a simple summarization:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;final model = await FlutterGemma.getActiveModel(  maxTokens: 512,  preferredBackend: PreferredBackend.gpu,);
final chat = await model.createChat();
await chat.addQueryChunk(  Message.text(    text: ‘Summarize this note in one sentence and suggest 2-3 tags: $noteContent’,    isUser: true,  ),);
final response = await chat.generateChatResponse();&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;That’s it. The inference runs entirely on-device. No API key, no network call, no cost. The response gives you a summary and tags you can parse and store.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3: Write the output to Firestore&lt;/strong&gt;&lt;br&gt;
Take the AI output and persist it to Firestore:&lt;/p&gt;
&lt;p&gt;`await FirebaseFirestore.instance&lt;br&gt;
  .collection(’notes’)&lt;br&gt;
  .doc(noteId)&lt;br&gt;
  .set({‘originalContent’: noteContent, // stays on-device or stored per your preference    &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;‘summary’: parsedSummary,    
‘tags’: parsedTags,    
‘userId’: FirebaseAuth.instance.currentUser!.uid,    
‘createdAt’: FieldValue.serverTimestamp(),});`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what goes to Firestore: the AI-generated summary and tags, not necessarily the raw note. You can store the original too if your app requires it — but the point is you’re in control of what leaves the device.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4: Read it back and sync&lt;/strong&gt;&lt;br&gt;
Firestore’s real-time listeners handle the sync automatically. Open the app on a second device and your tagged, summarized notes appear instantly — without any polling logic on your part. Firebase does the heavy lifting.&lt;/p&gt;
&lt;h3 id=&quot;real-talk-the-gotchas&quot;&gt;Real talk: the gotchas&lt;/h3&gt;
&lt;p&gt;I won’t sell you a perfect picture. Here’s what you’ll actually run into:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model download size&lt;/strong&gt;&lt;br&gt;
The Gemma 4 E2B model is ~1.5GB. This is a one-time download, but you need to handle it gracefully. Show a clear progress indicator on first launch. Consider letting users choose to download over WiFi only. The flutter_gemma package provides download progress callbacks — use them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cold start time&lt;/strong&gt;&lt;br&gt;
Loading the model into memory takes a few seconds on the first inference of a session. Show a loading state. After the model is warm, subsequent responses are fast. Set user expectations early — frame it as the app ‘preparing your AI’ rather than ‘loading.’&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know when to use cloud AI instead&lt;/strong&gt;&lt;br&gt;
On-device Gemma 4 is excellent for targeted tasks: summarization, classification, short generation, tagging. It’s not the right tool for complex multi-step reasoning, very long context windows, or tasks where you need GPT-4-level capability. Design your AI features to scope appropriately — use Gemma on-device for the 80% of tasks it handles well, and route complex queries to a cloud model if needed.&lt;/p&gt;
&lt;h3 id=&quot;the-payoff&quot;&gt;The payoff&lt;/h3&gt;
&lt;p&gt;Here’s what you get with this architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero ongoing AI cost.&lt;/strong&gt; No per-request charges, ever. The model is on the user’s device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full privacy.&lt;/strong&gt; User data is processed locally. You can credibly promise your users their data stays on their phone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Works offline.&lt;/strong&gt; AI features work without a connection. Firebase syncs when connectivity returns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scales for free.&lt;/strong&gt; Your 1,000th user costs you no more in AI inference than your first.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Firebase gives you the sync and persistence layer without building a backend. Gemma gives you the AI without paying per token. Together, they let you ship an AI-powered product that’s genuinely sustainable for an indie developer.&lt;/p&gt;
&lt;h3 id=&quot;what-i-m-building-with-this&quot;&gt;What I’m building with this&lt;/h3&gt;
&lt;p&gt;This is the exact architecture I’m using for my own product — built on Flutter, Gemma, and Firebase. I’ll be writing more about specific implementation decisions as I go, including how I’m using Gemma’s multimodal vision support and how I structure Firestore for AI-generated data.&lt;/p&gt;
&lt;p&gt;If you’re building something similar — or thinking about it — hit reply. I’d love to hear what you’re working on.&lt;/p&gt;
&lt;p&gt;Next up: Adding multimodal vision to your Flutter + Gemma app — letting the model read images directly, entirely on-device.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17447/building-a-zero-cost-ai-feature-in-flutter-with-gemma-4-firebase</guid>
<pubDate>Mon, 11 May 2026 16:39:31 +0000</pubDate>
</item>
<item>
<title>I Got 4 PRs Merged to the Flutter Framework. Here's Exactly How</title>
<link>https://coderlegion.com/17387/i-got-4-prs-merged-to-the-flutter-framework-heres-exactly-how</link>
<description>&lt;h2 id=&quot;i-got-4-prs-merged-to-the-flutter-framework-here-s-exactly-how&quot;&gt;I Got 4 PRs Merged to the Flutter Framework. Here's Exactly How&lt;/h2&gt;
&lt;p&gt;I never thought I'd contribute to a framework used by millions of developers.&lt;/p&gt;
&lt;p&gt;Flutter felt like this massive codebase maintained by Google engineers, way out of my league. But three months ago, I decided to try anyway. Today, I have 4 pull requests merged into the official Flutter repository (flutter/flutter), reviewed and approved by the Flutter team at Google.&lt;/p&gt;
&lt;p&gt;Here is exactly how I did it, step by step.&lt;/p&gt;
&lt;h3 id=&quot;why-i-wanted-to-contribute&quot;&gt;Why I Wanted to Contribute&lt;/h3&gt;
&lt;p&gt;I have been building Flutter apps for over 5 years across fintech, e-commerce, IoT, real estate, and healthcare products. I use Flutter every single day. But I had never looked under the hood.&lt;/p&gt;
&lt;p&gt;Three things pushed me to finally try:&lt;/p&gt;
&lt;p&gt;I wanted to understand how Flutter actually works at the framework level, not just how to use it. I wanted to give back to a tool that has been central to my entire career. And I wanted to stand out as a developer, because very few Flutter developers ever contribute back to the framework itself.&lt;/p&gt;
&lt;p&gt;So I went looking for a way in.&lt;/p&gt;
&lt;h3 id=&quot;step-1-finding-the-right-issue&quot;&gt;Step 1: Finding the Right Issue&lt;/h3&gt;
&lt;p&gt;I went to the Flutter GitHub repository and started filtering issues. The key is not to look for something impressive on your first try. You are looking for something clearly defined, well-scoped, and achievable without deep internal knowledge of the codebase.&lt;/p&gt;
&lt;p&gt;I found &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/issues/177414&quot;&gt;Issue #177414&lt;/a&gt;, a cleanup task to remove cross-imports from widget test files. Specifically, many test files in the widgets package were importing from Material or Cupertino packages when they should only be importing from the core widgets or flutter_test packages. This created unnecessary coupling between layers of the framework.&lt;/p&gt;
&lt;p&gt;It was a perfect entry point because the problem was clearly described, the fix pattern was consistent across multiple files, and each file could be a separate PR, meaning one issue could generate several contributions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Look for labels like &quot;good first issue&quot;, &quot;a: accessibility&quot;, or &quot;cleanup&quot;. Avoid anything marked &quot;severe&quot; or &quot;P0&quot; until you know the codebase well.&lt;/p&gt;
&lt;h3 id=&quot;step-2-understanding-the-codebase-before-touching-it&quot;&gt;Step 2: Understanding the Codebase Before Touching It&lt;/h3&gt;
&lt;p&gt;Before writing a single line of code, I did the following:&lt;/p&gt;
&lt;p&gt;I read the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/blob/master/CONTRIBUTING.md&quot;&gt;CONTRIBUTING.md&lt;/a&gt; file in full. Every open source project has different rules about branch naming, commit messages, PR descriptions, and test requirements. Flutter's is thorough and worth reading carefully.&lt;/p&gt;
&lt;p&gt;I forked the repository and set up Flutter from source locally. This is different from installing Flutter normally. You are cloning the engine and framework, not just downloading a binary.&lt;/p&gt;
&lt;p&gt;I found the specific test files mentioned in the issue and read them to understand what &quot;correct&quot; looked like versus what needed fixing.&lt;/p&gt;
&lt;p&gt;The problem in these files was that widget tests were using MaterialApp or CupertinoApp as their test wrapper, which pulled in the entire Material or Cupertino layer unnecessarily. The correct approach was to use framework-level wrappers that do not carry that dependency.&lt;/p&gt;
&lt;h3 id=&quot;step-3-making-the-first-fix&quot;&gt;Step 3: Making the First Fix&lt;/h3&gt;
&lt;p&gt;I started with autofill_group_test.dart for &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/181903&quot;&gt;PR #181903&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The change sounds simple, and technically it is. But getting it right requires understanding why the dependency exists in the first place and what the correct replacement is. Reviewers will reject a change that fixes the import but breaks the intent of the test.&lt;/p&gt;
&lt;p&gt;Here is the pattern I followed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Before: importing Material when it is not needed
import 'package:flutter/material.dart';

testWidgets('autofill group test', (WidgetTester tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: AutofillGroup(
        child: Column(children: [...]),
      ),
    ),
  );
});

// After: using only what the test actually needs
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

testWidgets('autofill group test', (WidgetTester tester) async {
  await tester.pumpWidget(
    Directionality(
      textDirection: TextDirection.ltr,
      child: AutofillGroup(
        child: Column(children: [...]),
      ),
    ),
  );
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After making the changes I ran the tests locally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter test packages/flutter/test/widgets/autofill_group_test.dart
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All tests passed. Then I created the PR.&lt;/p&gt;
&lt;h3 id=&quot;step-4-writing-a-good-pull-request&quot;&gt;Step 4: Writing a Good Pull Request&lt;/h3&gt;
&lt;p&gt;The PR description matters as much as the code. Flutter maintainers review dozens of PRs. A clear, well-written description gets reviewed faster and builds trust with the team.&lt;/p&gt;
&lt;p&gt;For each of my PRs I included a reference to the parent issue (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/issues/177414&quot;&gt;#177414&lt;/a&gt;), a plain description of what I changed and why, and confirmation that I had run the relevant tests locally.&lt;/p&gt;
&lt;p&gt;My commit message format was consistent across all four PRs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Remove Material dependency from [filename]

Part of #177414
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-5-the-review-process&quot;&gt;Step 5: The Review Process&lt;/h3&gt;
&lt;p&gt;This is the part most people are afraid of. Google engineers reading your code.&lt;/p&gt;
&lt;p&gt;The reality is that Flutter maintainers are professional and constructive. They want contributions to succeed. On &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/181951&quot;&gt;PR #181951&lt;/a&gt; (Move SelectionArea web test from widgets to material folder) I received 17 review comments. On &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/181903&quot;&gt;#181903&lt;/a&gt; I received 12. These were not rejections. They were detailed, specific feedback that made the final code better.&lt;/p&gt;
&lt;p&gt;The review on #181951 was particularly educational. The SelectionArea web test needed to move from the widgets test folder to the material test folder because it relied on material-specific behavior. A reviewer caught a subtle dependency I had missed and walked me through the correct fix. That kind of direct feedback from framework engineers is genuinely invaluable.&lt;/p&gt;
&lt;p&gt;I addressed every comment, pushed updates, and waited for re-review. The turnaround was faster than I expected.&lt;/p&gt;
&lt;h3 id=&quot;the-4-merged-prs&quot;&gt;The 4 Merged PRs&lt;/h3&gt;
&lt;p&gt;Here is the full list of what got merged:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/181903&quot;&gt;PR #181903&lt;/a&gt;&lt;/strong&gt; — Refactor autofill_group_test.dart to remove Material dependencies&lt;br&gt;
Merged February 19. 12 review comments. Tagged: framework.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/181951&quot;&gt;PR #181951&lt;/a&gt;&lt;/strong&gt; — Move SelectionArea web test from widgets to material folder&lt;br&gt;
Merged February 19. 17 review comments. Tagged: f:material design, framework.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/182141&quot;&gt;PR #182141&lt;/a&gt;&lt;/strong&gt; — Remove Material dependency from transformed_scrollable_test.dart&lt;br&gt;
Merged February 13. Tagged: f:scrolling, framework.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/pull/182211&quot;&gt;PR #182211&lt;/a&gt;&lt;/strong&gt; — Remove Material dependency from semantics_keep_alive_offstage_test.dart&lt;br&gt;
Merged February 13. Tagged: a:accessibility, framework.&lt;/p&gt;
&lt;p&gt;Each one built on the last. By the fourth PR I knew exactly what reviewers would look for and I addressed those things proactively in the code before they asked.&lt;/p&gt;
&lt;h3 id=&quot;what-contributing-to-flutter-actually-taught-me&quot;&gt;What Contributing to Flutter Actually Taught Me&lt;/h3&gt;
&lt;p&gt;The technical lesson is about dependency management and layered architecture. The Flutter framework is deliberately structured so that lower-level packages do not depend on higher-level ones. When test files break that rule, even accidentally, it signals a coupling problem that could affect how the framework evolves. Understanding this made me a better architect in my own apps.&lt;/p&gt;
&lt;p&gt;The broader lesson is about reading a large codebase with confidence. When I started, flutter/flutter felt enormous and intimidating. After four PRs, I navigate it comfortably. That skill transfers directly to joining any large engineering team or contributing to any open source project.&lt;/p&gt;
&lt;p&gt;The professional lesson is that open source contributions are not just resume lines. The review conversations, the back-and-forth with maintainers, the process of getting your code to a standard that ships in a framework used by millions, that is real engineering experience that very few developers have.&lt;/p&gt;
&lt;h3 id=&quot;how-to-start-your-own-flutter-contribution&quot;&gt;How to Start Your Own Flutter Contribution&lt;/h3&gt;
&lt;p&gt;Go to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/issues/177414&quot;&gt;github.com/flutter/flutter/issues/177414&lt;/a&gt;. There are still files in the issue that need fixing. Pick one that has not been claimed, follow the pattern I described, and submit a PR.&lt;/p&gt;
&lt;p&gt;Read &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/flutter/flutter/blob/master/CONTRIBUTING.md&quot;&gt;CONTRIBUTING.md&lt;/a&gt; before you touch any code. Set up Flutter from source. Run the tests locally before pushing anything. Write a clear PR description that references the issue.&lt;/p&gt;
&lt;p&gt;The Flutter team is genuinely welcoming to new contributors. The codebase is well-documented. And seeing your name in the merged commits of a framework used by millions of developers worldwide is worth every hour of the process.&lt;/p&gt;
&lt;h3 id=&quot;let-s-connect&quot;&gt;Let's Connect&lt;/h3&gt;
&lt;p&gt;If you are working through your first Flutter contribution or have questions about open source in general, reach out. I am happy to help.&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/gbolahan507&quot;&gt;github.com/gbolahan507&lt;/a&gt;&lt;br&gt;
LinkedIn: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/gbolahan507/&quot;&gt;linkedin.com/in/gbolahan507&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Happy contributing.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17387/i-got-4-prs-merged-to-the-flutter-framework-heres-exactly-how</guid>
<pubDate>Mon, 11 May 2026 06:36:37 +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-1&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>Why Senior Architects are Banning AI from their Learning Stack</title>
<link>https://coderlegion.com/17067/why-senior-architects-are-banning-ai-from-their-learning-stack</link>
<description>&lt;p&gt;In 2026, the software industry is fiercely obsessed with one metric: velocity. We now have generative tools capable of scaffolding an entire Flutter CRUD application from a single sentence prompt.&lt;/p&gt;
&lt;p&gt;But for a Senior Architect, velocity without deep understanding isn't a superpower. It's just technical debt with a faster delivery date. My deep motivation for learning new stacks like migrating from Flutter to React Native and Expo without the aid of AI is incredibly simple: Intuition cannot be outsourced.&lt;/p&gt;
&lt;p&gt;To lead a high performing engineering team, you must understand the &quot;metal.&quot; You need to know exactly why a frame drops, how memory is allocated and garbage collected in the heap, and the precise mechanics of how the bridge communicates. If an AI writes your code, you aren't actually learning the framework; you are merely learning how to review a machine's guesswork.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &quot;Black Box&quot; of Automated Development&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The real danger of AI-driven development isn't that the code fails to work it's that it frequently works by accident. For enterprise-level applications, this creates three critical, systemic failures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Architectural Drift:&lt;/strong&gt; AI fundamentally favors &quot;flat&quot; and expedient structures. It will happily embed complex business logic directly inside UI widgets because that is the shortest mathematical path to a functional output. In a scalable, long-term project, this violates Clean Architecture principles and renders comprehensive testing nearly impossible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Debugging Ceiling:&lt;/strong&gt; When you inevitably hit a high-level performance bottleneck such as noticeable lag rendering a real-time logistics map an AI-reliant developer hits a wall. Because they never manually traced the Widget Lifecycle or fully understood the Render Pipeline, they lack the foundational context required to isolate the root cause.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency Bloat:&lt;/strong&gt; LLMs frequently suggest outdated, heavy, or bloated packages to solve relatively simple problems that a senior engineer would handle natively perhaps with a custom Dart FFI or a lightweight platform channel.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The Solution: My Blueprint for Manual Mastery&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To remain a top-tier engineer, I follow a &quot;Manual First&quot; approach. Here is how I ensure I actually own my knowledge:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Master the Bridge, Not Just the Syntax I don't just learn how to render a list. I learn how the engine (like JavaScript/Hermes) communicates with native threads. Understanding this overhead is the difference between a &quot;working&quot; app and a &quot;smooth&quot; 120fps experience.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Implement Design Patterns Manually Instead of letting an LLM generate boilerplate, I manually implement patterns like BLoC. Writing the events and managing the streams myself builds a mental map of the data flow. This intuition is what allows me to predict bugs before they happen.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Optimize at the Hardware Level True seniority is found where the framework ends and the hardware begins. In my work on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/zerocopy/score&quot;&gt;zerocopy&lt;/a&gt; package, I used Dart FFI and C++ to eliminate the &quot;copy tax&quot; when handling large binary data. This level of optimization requires a deep understanding of memory that AI-generated code currently cannot replicate.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;AI is an Accelerator, Not a Teacher&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Don't get me wrong AI is incredibly important. Once you know a stack inside and out, absolutely use AI to write your repetitive boilerplate, generate your unit tests, and speed up your workflow. AI is an unmatched accelerator. But it is a terrible teacher of fundamentals.&lt;/p&gt;
&lt;p&gt;If you want to be an Architect who actually leads teams, builds scalable apps, and solves the impossible bugs, you have to build your mental map yourself. You have to write the state management manually. You have to understand how the JS engine actually talks to the native UI bridge. By choosing &quot;Manual Mastery&quot; when learning something new, you aren't being a dinosaur. You're building the foundation required to fix the complex problems that AI will inevitably create.&lt;/p&gt;
&lt;p&gt;(Want to see what this looks like in practice? Check out some of my deep-dive work at momarkhan.com).&lt;/p&gt;
&lt;p&gt;I'm curious to hear from other devs how do you balance using AI for speed vs actually learning the core of a new framework? Let's talk in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17067/why-senior-architects-are-banning-ai-from-their-learning-stack</guid>
<pubDate>Fri, 08 May 2026 13:43:06 +0000</pubDate>
</item>
<item>
<title>Minha Experiência Estruturando uma Pipeline Mobile CI/CD Flutter</title>
<link>https://coderlegion.com/17058/minha-experiencia-estruturando-uma-pipeline-mobile-ci-cd-flutter</link>
<description>&lt;p&gt;Recentemente, enfrentei o desafio de estruturar uma pipeline de CI/CD completa para um aplicativo corporativo desenvolvido em Flutter. O objetivo era claro: automatizar, proteger e agilizar o deploy para Android e iOS. Neste artigo, compartilho os aprendizados, as soluções para os obstáculos encontrados e os exemplos práticos que formaram a espinha dorsal dessa implementação.&lt;/p&gt;
&lt;h2 id=&quot;por-que-investir-em-ci-cd-mobile-&quot;&gt;Por que investir em CI/CD mobile?&lt;/h2&gt;
&lt;p&gt;Em um cenário de desenvolvimento ágil, automatizar os processos de build, teste e deploy não é um luxo, mas uma necessidade. Para aplicações mobile, essa urgência é amplificada: os builds são demorados e consomem recursos, as dependências evoluem rapidamente e cada loja de aplicativos (Google Play e App Store) impõe seu próprio conjunto de regras e exigências. Uma pipeline bem arquitetada garante entregas rápidas, seguras e com a consistência que um ambiente corporativo exige.&lt;/p&gt;
&lt;h2 id=&quot;estrutura-dos-workflows&quot;&gt;Estrutura dos Workflows&lt;/h2&gt;
&lt;p&gt;Para organizar o processo, a pipeline foi modularizada em workflows distintos no GitHub Actions. Cada workflow é uma sequência de jobs que rodam em ambientes específicos, conhecidos como runners.&lt;/p&gt;
&lt;p&gt;Runners: São as máquinas virtuais que executam seus jobs. Para builds Android, utilizamos os runners ubuntu-latest fornecidos pelo GitHub, que são eficientes e econômicos. Para iOS, a compilação exige o macOS. Como os runners macOS do GitHub têm um custo associado, optamos por configurar um runner self-hosted em uma máquina Mac local, garantindo controle total sobre o ambiente e otimização de custos.&lt;/p&gt;
&lt;p&gt;Dividimos nossa esteira em workflows específicos, cada um com sua responsabilidade:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feature-workflow.yml&lt;/code&gt;: Valida código novo, executa lint, testes unitários e análise SAST (Static Application Security Testing), garantindo qualidade e segurança desde o início.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;staging-workflow.yml&lt;/code&gt;: Prepara builds de homologação, rodando validações e testes automatizados.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mobile-workflow.yml&lt;/code&gt;: Geram builds de produção e fazem o deploy para Google Play e App Store.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;controle-dos-fluxos&quot;&gt;Controle dos Fluxos&lt;/h3&gt;
&lt;p&gt;Para garantir que cada etapa só rode no momento certo e que a pipeline de produção só seja executada após a validação em staging, utilizei gatilhos condicionais. Isso orquestra a transição entre ambientes de forma segura.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;on:
  workflow_dispatch: # Permite disparo manual
  workflow_run:
    workflows: [&quot;staging-workflow&quot;] # Nome do workflow que deve terminar
    types:
      - completed # Apenas quando ele for concluído

jobs:
  build:
    # A condição abaixo garante que o job só rode se o disparo for manual
    # ou se o workflow de staging anterior tiver sido concluído com sucesso.
    if: &amp;gt;
      github.event_name == 'workflow_dispatch' ||
      (github.event.workflow_run.conclusion == 'success' &amp;amp;&amp;amp; github.event.workflow_run.head_branch == 'staging')
    runs-on: ubuntu-latest
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Isso permite que o workflow seja disparado manualmente ou automaticamente após a conclusão bem-sucedida do workflow de staging. O uso do &lt;code&gt;workflow_dispatch&lt;/code&gt; também foi essencial para execuções manuais e testes pontuais.&lt;/p&gt;
&lt;h3 id=&quot;configura-o-do-ambiente&quot;&gt;Configuração do Ambiente&lt;/h3&gt;
&lt;p&gt;Antes de qualquer build, preparei o ambiente instalando Flutter, Java e aplicando cache para dependências, acelerando o processo e evitando downloads desnecessários:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Setup Flutter
  uses: subosito/flutter-action@v2
  with:
    flutter-version: ${{ env.FLUTTER_VERSION }}
    channel: stable
    cache: true

- name: Cache Flutter pub dependencies
  uses: actions/cache@v4
  with:
    path: ~/.pub-cache
    key: ${{ runner.os }}-pub-${{ env.FLUTTER_VERSION }}-${{ hashFiles('**/pubspec.lock') }}

- name: Setup Java
  uses: actions/setup-java@v4
  with:
    distribution: 'temurin'
    java-version: '17'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No iOS, também utilizei cache para CocoaPods e limpei o ambiente antes de cada build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Cache CocoaPods
  uses: actions/cache@v4
  with:
    path: ios/Pods
    key: ${{ runner.os }}-pods-${{ hashFiles('**/pubspec.lock') }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;assinatura-dos-apps&quot;&gt;Assinatura dos Apps&lt;/h3&gt;
&lt;p&gt;A assinatura digital é obrigatória para publicação nas lojas. No Android, trabalhei com keystore e no iOS, com certificados e provisioning profiles:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Setup iOS certificates
  uses: apple-actions/import-codesign-certs@v2
  with:
    p12-file-base64: ${{ secrets.CERTIFICATE_BASE64 }}
    p12-password: ${{ secrets.CERTIFICATE_PASSWORD }}

- name: Download Provisioning Profiles
  uses: apple-actions/download-provisioning-profiles@v2
  with:
    bundle-id: com.seuapp.id
    profile-type: IOS_APP_STORE
    issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
    api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
    api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;O GitHub trabalha de uma forma bem peculiar com certificados, exigindo que o sejam salvos como secret em base64 e decodificado em tempo de execução no runner.&lt;/p&gt;
&lt;p&gt;No Android, o keystore foi salvo via secret em base64 e decodificado no runner:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Setup Android signing
  run: |
    echo &quot;$KEYSTORE_BASE64&quot; | base64 -d &amp;gt; android/app/keystore.jks
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;jobs-de-qualidade&quot;&gt;Jobs de Qualidade&lt;/h3&gt;
&lt;p&gt;Implementei jobs de lint, testes unitários e análise estática de segurança (SAST) para garantir qualidade e segurança:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Lint Flutter
  run: flutter analyze

- name: Testes Unitários
  run: flutter test --coverage

- name: SAST com MobSF
  uses: MobSF/mobsfscan@main
  with:
    args: .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;O SAST foi crucial para identificar vulnerabilidades cedo, especialmente em apps que lidam com dados sensíveis. Cada job gera relatórios que ajudam a manter a qualidade do código alta.&lt;/p&gt;
&lt;h3 id=&quot;deploy-automatizado&quot;&gt;Deploy Automatizado&lt;/h3&gt;
&lt;p&gt;Com os builds assinados, o último passo é o deploy, utilizei actions específicas para upload dos builds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Android:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;r0adkll/upload-google-play@v1&lt;/code&gt; para enviar o bundle diretamente à Google Play.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;apple-actions/upload-testflight-build@v3&lt;/code&gt; para subir o IPA ao TestFlight.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Exemplo de upload para TestFlight:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Upload app to TestFlight
  uses: apple-actions/upload-testflight-build@v3
  with: 
    app-path: ${{ steps.find-ipa.outputs.ipa-path }}
    issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
    api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
    api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;dificuldades-e-aprendizados&quot;&gt;Dificuldades e Aprendizados&lt;/h3&gt;
&lt;p&gt;Um dos maiores desafios foi adaptar o pipeline iOS para rodar em runner Mac self-hosted, já que o GitHub Actions não oferece MacOS gratuito. Também precisei ajustar versões de Java e Gradle para evitar conflitos e falhas silenciosas nos builds Android.&lt;/p&gt;
&lt;p&gt;No fim, consegui uma esteira automatizada, segura e eficiente, que valida, testa, assina e publica os apps nas lojas com mínimo esforço manual. Se você está montando sua pipeline mobile, recomendo investir tempo nos jobs de qualidade, na configuração de secrets e no controle dos fluxos. O resultado compensa!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17058/minha-experiencia-estruturando-uma-pipeline-mobile-ci-cd-flutter</guid>
<pubDate>Fri, 08 May 2026 12:19:10 +0000</pubDate>
</item>
<item>
<title>Optimizing Flutter Apps: A Deep Dive into Dart DevTools</title>
<link>https://coderlegion.com/17045/optimizing-flutter-apps-a-deep-dive-into-dart-devtools</link>
<description>&lt;p&gt;Most Flutter developers know how to build apps. Far fewer know how to build &lt;em&gt;fast&lt;/em&gt; ones. The gap between a good app and a great one often isn't in the features. It's in the frames dropped, the memory leaked, and the milliseconds lost.&lt;/p&gt;
&lt;p&gt;Dart DevTools is your toolkit for closing that gap. This guide walks through each of its major panels, what they're good for, and how to actually use them on a real app.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Before you start:&lt;/strong&gt; Always profile in &lt;strong&gt;profile mode&lt;/strong&gt;, not debug mode. Debug mode runs with extra assertions and overhead that will skew every reading you take.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter run --profile
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-flutter-inspector-the-widget-tree-x-ray&quot;&gt;1. Flutter Inspector — The Widget Tree X-Ray&lt;/h3&gt;
&lt;p&gt;The Flutter Inspector gives you a live, interactive view of your widget tree. Think of it as browser DevTools, but for your UI hierarchy. Here are the four toggles you'll use most.&lt;/p&gt;
&lt;h4 id=&quot;slow-animations&quot;&gt;Slow Animations&lt;/h4&gt;
&lt;p&gt;Complex animations can be impossible to debug at full speed. Toggling &lt;strong&gt;Slow Animations&lt;/strong&gt; in the Inspector, or setting this in code, drops them to 20% speed so you can catch glitches frame by frame.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'package:flutter/scheduler.dart';

void main() {
  timeDilation = 5.0; // 5× slower
  runApp(const MyApp());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;guidelines-amp-baselines&quot;&gt;Guidelines &amp;amp; Baselines&lt;/h4&gt;
&lt;p&gt;Enables visual overlays that draw:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scroll direction indicators on scrollable widgets&lt;/li&gt;
&lt;li&gt;Widget boundaries (render object outlines)&lt;/li&gt;
&lt;li&gt;Text baselines to catch misaligned typography&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is particularly useful when you suspect a layout issue but cannot pinpoint which widget is the culprit.&lt;/p&gt;
&lt;h4 id=&quot;highlight-repaints&quot;&gt;Highlight Repaints&lt;/h4&gt;
&lt;div align=&quot;center&quot;&gt;
  &lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12462702801371394349&quot; width=&quot;45%&quot; style=&quot;border-radius:50px&quot; alt=&quot;image&quot;&gt;
&lt;p&gt;  &lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1234491430867038178&quot; width=&quot;44%&quot; style=&quot;border-radius:50px&quot; alt=&quot;image&quot;&gt;&lt;br&gt;
&lt;/p&gt;&lt;/div&gt;&lt;br&gt;
Every time a widget repaints, it flashes a continuous stream of color. If your entire screen is flashing because a small loading spinner is animating, you have a problem.
&lt;p&gt;I once traced a scrolling jank issue to a blinking typing indicator that was forcing an entire chat screen to repaint every frame.&lt;/p&gt;
&lt;p&gt;The fix is a &lt;code&gt;RepaintBoundary&lt;/code&gt;. It tells Flutter to isolate that subtree into its own render layer, so its repaints do not cascade upward.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;RepaintBoundary(
  child: MyAnimatedWidget(),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this around widgets that animate independently, such as loaders, progress bars, and live counters.&lt;/p&gt;
&lt;h4 id=&quot;highlight-oversized-images&quot;&gt;Highlight Oversized Images&lt;/h4&gt;
&lt;p&gt;If an image is decoded at 2000×2000px but displayed at 100×100px, you're wasting memory on 39,900 pixels that will never be seen. This toggle shows them as negative and rotates them as shown in the image&lt;br&gt;
&lt;/p&gt;&lt;div align=&quot;center&quot;&gt;&lt;br&gt;
  &lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13324385877305745567&quot; width=&quot;45%&quot; style=&quot;border-radius:50px&quot; alt=&quot;image&quot;&gt;&lt;br&gt;
&lt;/div&gt;
&lt;p&gt;The fix is straightforward. Constrain decoding at the source:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Image.network(
  'https://example.com/large-image.jpg',
  cacheWidth: 200,
  cacheHeight: 200,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flutter will downsample the image before caching it, significantly reducing memory pressure on image-heavy screens.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-app-size-analysis-know-what-you-re-shipping&quot;&gt;2. App Size Analysis — Know What You're Shipping&lt;/h3&gt;
&lt;p&gt;A bloated app takes longer to download, longer to install, and gives a worse first impression before the user has even launched it. The &lt;code&gt;--analyze-size&lt;/code&gt; flag gives you a breakdown of exactly what's eating into your bundle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For Android&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter build apk --analyze-size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For iOS&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flutter build ipa --analyze-size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This outputs a JSON file that you import directly into DevTools. Once loaded, you get two views:&lt;/p&gt;
&lt;h4 id=&quot;dominator-tree&quot;&gt;Dominator Tree&lt;/h4&gt;
&lt;p&gt;Shows which packages are responsible for the largest chunks of your app size. If a utility package you barely use is claiming 3MB, it is worth reconsidering.&lt;/p&gt;
&lt;h4 id=&quot;call-graph&quot;&gt;Call Graph&lt;/h4&gt;
&lt;p&gt;Maps how your dependencies import each other. This is how you find transitive dependencies you did not know you had. A package pulls another package, which pulls another, each silently adding weight.&lt;/p&gt;
&lt;p&gt;Size is a feature.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=8905883950125565687&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-performance-amp-jank-tracking-chasing-smooth-frames&quot;&gt;3. Performance &amp;amp; Jank Tracking — Chasing Smooth Frames&lt;/h3&gt;
&lt;p&gt;The target is 60 frames per second. That gives each frame exactly &lt;strong&gt;16.6 milliseconds&lt;/strong&gt; to build and render. Any frame that exceeds this budget is called a &lt;strong&gt;jank&lt;/strong&gt;, and users feel every one of them, even if they cannot name what's wrong.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Performance&lt;/strong&gt; tab lets you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Record a session of real app interactions&lt;/li&gt;
&lt;li&gt;View a flame chart of frame build times&lt;/li&gt;
&lt;li&gt;Click into expensive frames to see exactly which build, layout, or paint call consumed the most time&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The key is to record &lt;em&gt;specific interactions&lt;/em&gt; rather than letting it run indefinitely. Navigate to the screen you suspect is slow, hit record, reproduce the issue, then stop. A focused recording is far easier to read than five minutes of noise.&lt;/p&gt;
&lt;p&gt;Look for the red frames in the timeline. Those are your jank frames. Drill into them, and you'll usually find one of three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An expensive &lt;code&gt;build()&lt;/code&gt; method&lt;/li&gt;
&lt;li&gt;An unnecessary full-tree rebuild&lt;/li&gt;
&lt;li&gt;A synchronous operation blocking the main thread&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Heavy JSON parsing, large computations, or image processing should not run on the UI isolate. Move them to another isolate using &lt;code&gt;compute()&lt;/code&gt; whenever possible.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final result = await compute(parseLargeJson, jsonString);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;section&quot;&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=4199897461815345682&quot; alt=&quot;&quot;&gt;&lt;/h3&gt;
&lt;h3 id=&quot;4-network-amp-cpu-profiling-under-the-hood&quot;&gt;4. Network &amp;amp; CPU Profiling — Under the Hood&lt;/h3&gt;
&lt;h4 id=&quot;network-tab&quot;&gt;Network Tab&lt;/h4&gt;
&lt;p&gt;If you've ever tried to debug an API issue by reading through a wall of &lt;code&gt;flutter: {...}&lt;/code&gt; in the debug console, the Network tab will feel like a revelation.&lt;/p&gt;
&lt;p&gt;It captures every HTTP request your app makes and displays:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Full request and response headers&lt;/li&gt;
&lt;li&gt;Response body and status codes&lt;/li&gt;
&lt;li&gt;Request timing and duration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No more print-statement archaeology. You can see at a glance if a request is failing, slow, or returning unexpected data.&lt;/p&gt;
&lt;h4 id=&quot;cpu-profiler&quot;&gt;CPU Profiler&lt;/h4&gt;
&lt;p&gt;The CPU Profiler records a flame chart of method calls while your app runs. When something feels sluggish but the Performance tab does not show obvious jank, this is where you look.&lt;/p&gt;
&lt;p&gt;It is especially good for catching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Expensive operations accidentally running on the main isolate&lt;/li&gt;
&lt;li&gt;Framework callbacks firing more often than expected&lt;/li&gt;
&lt;li&gt;Third-party packages with surprisingly heavy footprints&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-memory-amp-debugger-stability-under-pressure&quot;&gt;5. Memory &amp;amp; Debugger — Stability Under Pressure&lt;/h3&gt;
&lt;h4 id=&quot;memory-tab&quot;&gt;Memory Tab&lt;/h4&gt;
&lt;p&gt;Memory leaks are silent killers. Your app might run perfectly in testing and degrade over time in production as memory accumulates and is never released.&lt;/p&gt;
&lt;p&gt;The most common culprit in Flutter is forgetting to &lt;code&gt;dispose()&lt;/code&gt; objects that hold onto resources.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class MyWidget extends StatefulWidget { ... }

class _MyWidgetState extends State&amp;lt;MyWidget&amp;gt;
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Memory tab shows a real-time chart of heap allocation. If memory climbs steadily as you navigate through your app and never comes back down, you likely have a leak.&lt;/p&gt;
&lt;p&gt;Reproduce the navigation repeatedly and watch the graph. A healthy app's memory should stabilize over time.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7694226450075472459&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;debugger&quot;&gt;Debugger&lt;/h4&gt;
&lt;p&gt;The built-in debugger supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Breakpoints&lt;/li&gt;
&lt;li&gt;Step-through execution&lt;/li&gt;
&lt;li&gt;Variable inspection&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All without leaving DevTools.&lt;/p&gt;
&lt;p&gt;This is useful for tracking down logic bugs that only appear in certain states, or for understanding a complex call chain without littering your codebase with &lt;code&gt;print()&lt;/code&gt; calls.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-principles-to-take-away&quot;&gt;Key Principles to Take Away&lt;/h3&gt;
&lt;h4 id=&quot;profile-mode-is-non-negotiable&quot;&gt;Profile Mode Is Non-Negotiable&lt;/h4&gt;
&lt;p&gt;Debug mode adds overhead that makes everything look slower than it is. Measure performance in profile mode, always.&lt;/p&gt;
&lt;h4 id=&quot;do-not-optimize-blindly&quot;&gt;Do Not Optimize Blindly&lt;/h4&gt;
&lt;p&gt;Reach for DevTools when you observe a real problem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A user complaint&lt;/li&gt;
&lt;li&gt;A visible stutter&lt;/li&gt;
&lt;li&gt;A reported crash&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Premature optimization creates complexity without measurable benefit.&lt;/p&gt;
&lt;h4 id=&quot;older-devices-are-your-benchmark&quot;&gt;Older Devices Are Your Benchmark&lt;/h4&gt;
&lt;p&gt;An app that runs smoothly on a flagship device might choke on a three-year-old mid-range phone. Test on the weakest hardware your users are likely to have.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Dart DevTools removes the guesswork from Flutter performance.&lt;/p&gt;
&lt;p&gt;Each panel answers a specific category of question:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What's rendering&lt;/li&gt;
&lt;li&gt;What's too big&lt;/li&gt;
&lt;li&gt;What's too slow&lt;/li&gt;
&lt;li&gt;What's leaking&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use them together, and you'll spend less time wondering why your app feels sluggish and more time fixing it.&lt;/p&gt;
&lt;p&gt;Thanks to &lt;strong&gt;Aswin Gopinathan&lt;/strong&gt; for this wonderful workshop, from which I got the knowledge for this blog. Do check it out on &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/dTNMxeIfS-o?si=GttEhpSuIeRD8s8i&quot;&gt;Youtube&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17045/optimizing-flutter-apps-a-deep-dive-into-dart-devtools</guid>
<pubDate>Fri, 08 May 2026 09:59:05 +0000</pubDate>
</item>
<item>
<title>Building a social app for Latam as a solo founder</title>
<link>https://coderlegion.com/16984/building-a-social-app-for-latam-as-a-solo-founder</link>
<description>&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://peekr.app/en/buzz/building-a-social-app-for-latam-as-a-solo-founder&quot;&gt;Original Article Link&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;The real decisions behind Peekr — stack choices, designing for Spanish and Portuguese markets, building an algorithmic feed without a data team, and the honest tradeoffs of going solo.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I've been building Peekr for about six months. It's a social app for movies and TV shows — think Letterboxd meets a social graph, designed specifically for Latin America. Users rate content, follow friends, build watchlists, and get recommendations based on what people they actually know are watching.&lt;/p&gt;
&lt;p&gt;I'm building it alone. One founder, no co-founder, no team. Everything from the Flutter app to the Supabase backend to the Next.js web app to the automated content pipeline — all of it. This article is about the decisions that actually mattered, the ones I'd make the same way again, and the ones I wouldn't.&lt;/p&gt;
&lt;h3 id=&quot;why-latam-and-why-that-changed-everything&quot;&gt;&lt;strong&gt;Why LATAM, and why that changed everything&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Most social apps treat Latin America as an afterthought — an English product with a Spanish translation bolted on afterward. Localization as a checkbox. I wanted to build something where Spanish and Portuguese were first-class citizens from day one, not just in copy but in content, in cultural references, in the algorithm.&lt;/p&gt;
&lt;p&gt;This decision had real technical consequences. I use es-419 (Latin American Spanish ISO code) everywhere TMDB supports it, not es-ES. I built a multilingual article system from the start — every piece of content is authored in three languages simultaneously. The routing is /es/, /en/, /pt/ with proper hreflang alternates and JSON-LD structured data for each. Google treats them as separate pages targeting different markets.&lt;/p&gt;
&lt;p&gt;It also changed my content strategy entirely. The Hollywood trade press (Variety, THR) has zero search demand in LATAM. Nobody in Argentina searches for &quot;Gwyneth Paltrow goop discount.&quot; They search for &quot;El Eternauta qué ver después&quot; or &quot;filmografía Jackie Chan.&quot; I had to build my content pipeline around that reality, not copy what English-language apps do.&lt;/p&gt;
&lt;h3 id=&quot;the-stack-flutter-supabase-next-js&quot;&gt;&lt;strong&gt;The stack: Flutter + Supabase + Next.js&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;I chose Flutter for the mobile app. In 2024, this is still a somewhat contrarian choice — React Native has more ecosystem, more hiring pool, more Stack Overflow answers. But I'd used Flutter before and I knew I could ship faster alone. One codebase for iOS and Android, hot reload, excellent widget system. The productivity advantage of knowing a framework deeply beats the theoretical advantages of a more popular one when you're a team of one.&lt;/p&gt;
&lt;p&gt;Supabase was the clearest decision I made. Postgres at the core means I can write real SQL — window functions, CTEs, complex joins — without fighting an ORM abstraction. Row Level Security handles auth at the database level. Edge Functions (Deno) let me run server-side logic without managing infrastructure. Realtime subscriptions power the social feed. The entire backend is one project, no microservices, no containers to orchestrate.&lt;/p&gt;
&lt;p&gt;The web app is Next.js on the App Router, deployed on Vercel. Mostly for SEO — the mobile app is the primary product, but a crawlable web presence with proper meta tags, OpenGraph, and structured data is how you get indexed by Google before you have enough backlinks to matter. Actor pages, title pages, article pages — all server-rendered, all indexed.&lt;/p&gt;
&lt;h3 id=&quot;building-an-algorithmic-feed-without-a-data-team&quot;&gt;&lt;strong&gt;Building an algorithmic feed without a data team&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;The social feed in Peekr is the hardest engineering problem I've worked on as a solo developer. The goal is to surface content that's genuinely interesting — not just chronological, not just popular, but personalized to your specific social graph and taste profile.&lt;/p&gt;
&lt;p&gt;I built it in Postgres. A single SQL query with a weighted scoring function: recency, engagement velocity, social graph proximity (do you follow the person who rated this?), genre affinity, and a novelty penalty to avoid showing the same titles repeatedly. The query runs in under 100ms for most users. No Redis, no separate recommendation service, no ML pipeline.&lt;/p&gt;
&lt;p&gt;Is it as good as what Netflix has? Obviously not. Is it good enough to be useful and feel personal? Yes. The insight is that for a social app at early scale, the social graph signal is so strong that you don't need sophisticated ML — if your friend who has similar taste just watched something and rated it 8/10, that's more valuable than any collaborative filtering model.&lt;/p&gt;
&lt;h3 id=&quot;the-content-pipeline-automating-what-a-team-would-do&quot;&gt;&lt;strong&gt;The content pipeline: automating what a team would do&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;One person can't produce enough content to keep a social platform active. I built an automated pipeline using Supabase Edge Functions, Gemini 2.0 Flash, and pg_cron.&lt;/p&gt;
&lt;p&gt;The daily brief system sends me three content options every morning via Telegram. I approve one, it generates a full Instagram carousel with AI-written copy and poster images, and schedules it for publication. No manual writing, no manual posting. The approval step keeps me in the loop without creating a bottleneck.&lt;/p&gt;
&lt;p&gt;For SEO, I built a separate content queue: actor filmographies, &quot;what to watch after X&quot; articles, each generated in Spanish, English, and Portuguese simultaneously. Two articles publish automatically every day, backdrops from TMDB as hero images (landscape format — poster crops look terrible in article headers), internal links to every actor and title page on the site. The whole system runs without me touching it.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-do-differently&quot;&gt;&lt;strong&gt;What I'd do differently&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;I underestimated how long SEO takes. The site has been indexed for six weeks and organic traffic is still minimal — mostly branded searches. The Google sandbox is real, and there's no shortcut. I should have started publishing content from month one, not month five.&lt;/p&gt;
&lt;p&gt;I also spent too long building features before validating retention. The social graph, the algorithmic feed, the personality test — all of it was built before I had enough users to know if any of it was working. A solo founder's biggest risk is building in isolation. I should have been more aggressive about getting real users earlier, even with a rougher product.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-answer-about-going-solo&quot;&gt;&lt;strong&gt;The honest answer about going solo&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;It's slower than having a team. Obvious. Less obvious: it's also more focused. Every hour I spend on a feature is an hour I'm not spending on something else, which forces brutal prioritization in a way that larger teams often avoid. There's no &quot;we could build both&quot; — there's only &quot;which one actually matters.&quot;&lt;/p&gt;
&lt;p&gt;The tools available to solo developers in 2025 are genuinely different from five years ago. AI coding assistants, managed backend infrastructure, automated content pipelines — the leverage per person has increased dramatically. I'm not sure Peekr would have been buildable by one person at this scope three years ago.&lt;/p&gt;
&lt;p&gt;Whether it works is still an open question. But it's shipping, it's indexed, and it's getting better every day — which is more than most side projects can say at month six.&lt;/p&gt;
&lt;p&gt;Jorge Martinelli is the founder of &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://peekr.app/auth/download-app&quot;&gt;Peekr&lt;/a&gt;&lt;/strong&gt;, a social app for movies and TV shows built for Latin America. You can follow the build on &lt;a rel=&quot;nofollow&quot; href=&quot;https://peerlist.io/&quot;&gt;Peerlist&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16984/building-a-social-app-for-latam-as-a-solo-founder</guid>
<pubDate>Thu, 07 May 2026 18:28:13 +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-1&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 Physics of Pixels: Flutter vs. React Native in 2026</title>
<link>https://coderlegion.com/15806/the-physics-of-pixels-flutter-vs-react-native-in-2026</link>
<description>&lt;p&gt;By early 2026, the &quot;Ecosystem War&quot; between Flutter and React Native (RN) has effectively reached a stalemate. If you look at GitHub stars, the volume of third-party packages, or the availability of AI-generated boilerplate, the two frameworks appear nearly identical. &lt;/p&gt;
&lt;p&gt;However, for senior engineers, the choice is no longer about &quot;which library is more popular.&quot; It’s about &lt;strong&gt;The Physics of Rendering.&lt;/strong&gt; When your application hits the performance ceiling, the only thing that matters is who owns the rendering pipeline.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-universal-rendering-model&quot;&gt;The Universal Rendering Model&lt;/h3&gt;
&lt;p&gt;Regardless of the framework, every modern UI boils down to a three-layer state machine. Understanding how data becomes a pixel requires looking at this closed loop:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Definition&lt;/strong&gt;: Defining the state and UI description (JSX / Widgets).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retention&lt;/strong&gt;: Persistent objects that manage state and logic (Fiber / Elements).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Computation&lt;/strong&gt;: The Diffing algorithm that calculates incremental updates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consumption&lt;/strong&gt;: The Rendering Engine that receives instructions and paints pixels.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-react-native-the-general-contractor-approach&quot;&gt;⚛️ React Native: The &quot;General Contractor&quot; Approach&lt;/h3&gt;
&lt;p&gt;React’s philosophy is to be the &lt;strong&gt;&quot;Brain of Declarative UI,&quot;&lt;/strong&gt; but it lacks its own &quot;hands.&quot; It manages the logic but outsources the actual work.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Layer &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Implementation &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Role &amp;amp; Constraints&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Description (JSX/TSX)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Blueprint Snapshot &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Immutable descriptions of &quot;what the UI should look like.&quot;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Management (Fiber Node)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;React Core Dispatcher &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Manages state and calculates Diffs. All optimizations (&lt;code&gt;memo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;) live here.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Execution (Host Environment)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;OS / Browser Engine&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;React has no drawing power. It sends instructions to the host (DOM or Native UI).&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;the-bottleneck-the-foreign-agency-problem-&quot;&gt;The Bottleneck: &quot;The Foreign Agency Problem&quot;&lt;/h4&gt;
&lt;p&gt;React Native is a &lt;strong&gt;UI Manipulation Framework.&lt;/strong&gt; Because the actual components (Buttons, Lists) are rendered by the OS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;High Communication Overhead&lt;/strong&gt;: Frequent updates (like high-speed scrolling) require constant &quot;bridging&quot; between the logic and the native UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black-Box Rendering&lt;/strong&gt;: Because rendering is delegated to Chrome or iOS/Android, React cannot intervene in &lt;em&gt;how&lt;/em&gt; a pixel is shaded. This is why complex, custom-path animations can feel &quot;heavy&quot; in RN.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;flutter-the-sovereign-ruler-approach&quot;&gt;Flutter: The &quot;Sovereign Ruler&quot; Approach&lt;/h3&gt;
&lt;p&gt;Flutter doesn’t trust the operating system’s components. It brings its own engine to the party. &lt;/p&gt;
&lt;p&gt;In Flutter, the &quot;Three-Tree&quot; architecture creates a full-stack closure from configuration to rasterization:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Layer &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Object &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Core Logic&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Blueprint (Configuration)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Widget&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Ultra-lightweight, immutable configurations created thousands of times per second.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Skeleton (Infrastructure)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Element&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;The bridge between the description and the rendered object; holds persistent state.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Rendering (Layout/Paint)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;RenderObject&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Flutter’s Secret Sauce.&lt;/strong&gt; It directly handles layout and painting instructions.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;the-advantage-rendering-sovereignty&quot;&gt;The Advantage: Rendering Sovereignty&lt;/h4&gt;
&lt;p&gt;Flutter utilizes its own rendering engine (evolving from Skia to &lt;strong&gt;Impeller&lt;/strong&gt;). This grants it &quot;Sovereign&quot; powers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Predictable Performance&lt;/strong&gt;: Since it doesn't rely on system components, the UI remains perfectly consistent across every device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Efficient Synthesis&lt;/strong&gt;: The transition from Widget → Element → RenderObject is highly optimized. Elements reuse RenderObjects whenever possible, drastically reducing the cost of expensive repaints.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-2026-decision-matrix-how-to-choose-&quot;&gt;The 2026 Decision Matrix: How to Choose?&lt;/h3&gt;
&lt;p&gt;We no longer choose based on &quot;community size.&quot; We choose based on &lt;strong&gt;Interactive Depth.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;technical-comparison-matrix&quot;&gt;Technical Comparison Matrix&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Feature &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;React Native (Delegated Model) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Flutter (Sovereign Model)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Rendering Granularity&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Component-level &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Pixel-level&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Pipeline Authority&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Delegated to Host &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Fully Autonomous&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Animation Ceiling&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Limited by Bridge efficiency &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Seamless and Precise&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Memory Footprint&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Lower (utilizes system UI) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Higher (ships a full engine)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;strategic-recommendation&quot;&gt;Strategic Recommendation&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Choose Flutter if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You are building &lt;strong&gt;Performance-Intensive Apps&lt;/strong&gt;: Video editors, high-frequency data dashboards, or precision design tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visual Consistency is Non-Negotiable&lt;/strong&gt;: Your design team demands pixel-perfect shadows and filters that look identical on a $200 Android and a $1200 iPhone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gamified UI&lt;/strong&gt;: Your app requires custom shaders or complex, fluid animations.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Choose React Native if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You are building &lt;strong&gt;Standard Business Apps&lt;/strong&gt;: E-commerce, social media, or CRUD tools where native components are already &quot;good enough.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web Ecosystem Integration&lt;/strong&gt;: You need to share heavy logic or hooks between your Web and Mobile teams.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dynamic Over-the-Air (OTA) Updates&lt;/strong&gt;: You prioritize the ability to push logic changes without a full App Store review cycle.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The Final Word&lt;/strong&gt;: Flutter is &lt;strong&gt;Building a World&lt;/strong&gt;, while React Native is &lt;strong&gt;Managing a World.&lt;/strong&gt; Understanding this distinction is the key to mastering the next era of cross-platform engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://we1.top&quot;&gt;author&lt;/a&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://airouter.me&quot;&gt;web&lt;/a&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://nestifyapp.org&quot;&gt;Organize&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15806/the-physics-of-pixels-flutter-vs-react-native-in-2026</guid>
<pubDate>Sat, 25 Apr 2026 11:40:52 +0000</pubDate>
</item>
<item>
<title>The Refresh Spinner That Wouldn't Let Go</title>
<link>https://coderlegion.com/15672/the-refresh-spinner-that-wouldnt-let-go</link>
<description>&lt;p&gt;Some bugs arrive with a very clear message. This one showed up as a loading gif that simply refused to leave the screen.&lt;/p&gt;
&lt;p&gt;The funny part was that the backend call had already succeeded.&lt;/p&gt;
&lt;p&gt;The dashboard was doing a pull-to-refresh. The request went out, a &lt;code&gt;200&lt;/code&gt; came back, the response payload looked fine, local state was being updated, and the logs were full of reassuring little breadcrumbs that normally tell you to move on with your life. But the UI stayed stuck in the refresh/loading state long enough to feel suspicious, and after a restart the crash log finally gave away what had been happening behind the scenes.&lt;/p&gt;
&lt;p&gt;It was one of those errors that sounds obvious once you see it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Bad state: Cannot use &quot;ref&quot; after the widget was disposed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That sentence did more work than the earlier logs.&lt;/p&gt;
&lt;h3 id=&quot;the-screen-was-technically-succeeding&quot;&gt;The screen was technically succeeding&lt;/h3&gt;
&lt;p&gt;What made this bug annoying was that the obvious suspects were innocent.&lt;/p&gt;
&lt;p&gt;It wasn't a failed API call.&lt;/p&gt;
&lt;p&gt;It wasn't a malformed response.&lt;/p&gt;
&lt;p&gt;It wasn't even the usual &quot;RefreshIndicator callback never completed&quot; problem.&lt;/p&gt;
&lt;p&gt;The refresh flow was actually finishing its network work. The broken part was what happened &lt;em&gt;after&lt;/em&gt; the await.&lt;/p&gt;
&lt;p&gt;In the dashboard package, the refresh handler looked roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; refreshDashboard(WidgetRef ref) async {
  await onRefresh();
  ref.invalidate(dashboardDataProvider);
  await ref.read(dashboardDataProvider.future);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That looks reasonable at first glance. Wait for the parent refresh, invalidate the provider, then wait for fresh data so the indicator can finish cleanly.&lt;/p&gt;
&lt;p&gt;The catch was that &lt;code&gt;onRefresh()&lt;/code&gt; did not just fetch data. It also triggered a loading mode in the parent screen.&lt;/p&gt;
&lt;p&gt;And that loading mode mattered more than I initially gave it credit for.&lt;/p&gt;
&lt;h3 id=&quot;where-things-got-interesting&quot;&gt;Where things got interesting&lt;/h3&gt;
&lt;p&gt;The dashboard had two layers of state going on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an app-level dashboard fetch that updated domain entities after the network request&lt;/li&gt;
&lt;li&gt;a package-level provider that exposed dashboard data to the newer UI flow&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That by itself is manageable. A little layered, but manageable.&lt;/p&gt;
&lt;p&gt;The real issue was that the parent screen treated &lt;em&gt;every&lt;/em&gt; dashboard fetch as if the app was starting from cold boot. So on pull-to-refresh, it set a loading flag that caused the entire dashboard body to be replaced with a full-page loader.&lt;/p&gt;
&lt;p&gt;That means the refreshing child widget got disposed while its own async refresh callback was still in flight.&lt;/p&gt;
&lt;p&gt;So the sequence was basically this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User pulls to refresh.&lt;/li&gt;
&lt;li&gt;Child widget starts &lt;code&gt;onRefresh()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Parent sets a global loading state.&lt;/li&gt;
&lt;li&gt;Parent rebuilds and swaps the dashboard out for a big loading gif.&lt;/li&gt;
&lt;li&gt;Child widget is disposed.&lt;/li&gt;
&lt;li&gt;Await returns.&lt;/li&gt;
&lt;li&gt;Child tries to use &lt;code&gt;ref.invalidate(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Riverpod says, very correctly, absolutely not.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That was the whole bug.&lt;/p&gt;
&lt;p&gt;Not network.&lt;br&gt;
Not parsing.&lt;br&gt;
Not authentication.&lt;br&gt;
Just a subtle disagreement between what &lt;em&gt;refresh&lt;/em&gt; means and what &lt;em&gt;initial loading&lt;/em&gt; means.&lt;/p&gt;
&lt;h3 id=&quot;a-loading-state-with-too-much-authority&quot;&gt;A loading state with too much authority&lt;/h3&gt;
&lt;p&gt;This is the part I keep running into in mobile apps: one boolean starts out innocent, then slowly becomes responsible for too many UI decisions.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;isLoading&lt;/code&gt; sounds harmless until it controls both of these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;whether the app has any usable data yet&lt;/li&gt;
&lt;li&gt;whether existing data should remain on screen during a background refresh&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are not the same thing.&lt;/p&gt;
&lt;p&gt;If the dashboard has never loaded before, a blocking full-screen loader is fine.&lt;/p&gt;
&lt;p&gt;If the dashboard is already visible and the user pulls to refresh, blanking out the entire screen is usually the wrong move anyway. It feels heavier than necessary, it creates more rebuild churn, and in my case it introduced a lifecycle bug because the widget that initiated the refresh no longer existed by the time the callback resumed.&lt;/p&gt;
&lt;p&gt;So the fix was less dramatic than the debugging session made it feel.&lt;/p&gt;
&lt;p&gt;I changed the parent dashboard screen to only show the full-page loader when there was no existing dashboard data yet. If data was already present, refresh would keep the current UI mounted and let the &lt;code&gt;RefreshIndicator&lt;/code&gt; own the loading experience.&lt;/p&gt;
&lt;p&gt;The change looked more like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; loadDashboard() async {
  final hasExistingData = ref.read(dashboardStateProvider).user != null;

  if (!hasExistingData) {
    setLoading(true);
  }

  final data = await repository.fetchDashboard();
  updateDashboardState(data);
  setLoading(false);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And in the build method:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final isLoading = ref.watch(dashboardStateProvider).isLoading;
final user = ref.watch(dashboardStateProvider).user;

if (isLoading &amp;amp;&amp;amp; user == null) {
  return const FullScreenLoader();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Small condition. Big difference.&lt;/p&gt;
&lt;h3 id=&quot;why-this-fix-felt-better-than-just-adding-guards&quot;&gt;Why this fix felt better than just adding guards&lt;/h3&gt;
&lt;p&gt;I could have stopped at a defensive patch inside the child refresh callback.&lt;/p&gt;
&lt;p&gt;Something like converting the widget to a &lt;code&gt;ConsumerStatefulWidget&lt;/code&gt;, checking &lt;code&gt;mounted&lt;/code&gt;, and avoiding any provider work after disposal. That would have made the crash go away.&lt;/p&gt;
&lt;p&gt;But it wouldn't have answered the more important question: &lt;em&gt;why was the widget being disposed during a simple refresh in the first place?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That is the part that actually mattered.&lt;/p&gt;
&lt;p&gt;When a screen already has usable data, I generally want refresh to be additive, not destructive. Keep the current content visible. Let the user stay oriented. Update in place. If the refresh fails, the user should still have the previous dashboard, not a blank page and an identity crisis.&lt;/p&gt;
&lt;p&gt;So the architectural correction was better than a pure lifecycle bandage.&lt;/p&gt;
&lt;p&gt;The mounted/disposal guard is still a good idea in general. But in this case, preserving the widget tree during refresh was the cleaner move because it aligned the UI behavior with what the user was actually doing.&lt;/p&gt;
&lt;h3 id=&quot;the-part-flutter-didn-t-exactly-hide-but-also-didn-t-volunteer&quot;&gt;The part Flutter didn't exactly hide, but also didn't volunteer&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;RefreshIndicator&lt;/code&gt; is very simple on paper: return a &lt;code&gt;Future&lt;/code&gt;, and it spins until that future completes.&lt;/p&gt;
&lt;p&gt;The trap is that the future can represent much more than a network call. It can include provider invalidation, storage reads, parent rebuilds, cross-package state synchronization, and whatever else your screen architecture has accumulated over time.&lt;/p&gt;
&lt;p&gt;So when the spinner doesn't stop, the answer is not always &quot;the API is hanging.&quot; Sometimes the future is completing into a widget tree that no longer exists.&lt;/p&gt;
&lt;p&gt;That was today's reminder that successful logs can be misleading. I had a perfectly healthy response and still a broken interaction because the state transitions around the response were fighting each other.&lt;/p&gt;
&lt;h3 id=&quot;what-i-m-keeping-from-this-one&quot;&gt;What I'm keeping from this one&lt;/h3&gt;
&lt;p&gt;I left the session with a stronger preference for separating &lt;strong&gt;initial load&lt;/strong&gt;, &lt;strong&gt;background refresh&lt;/strong&gt;, and &lt;strong&gt;empty state&lt;/strong&gt; as distinct UI situations, even if they all stem from the same repository method.&lt;/p&gt;
&lt;p&gt;They deserve different behavior.&lt;/p&gt;
&lt;p&gt;They often deserve different visuals.&lt;/p&gt;
&lt;p&gt;And if they all collapse into one loading flag, the code will usually work right up until it doesn't.&lt;/p&gt;
&lt;p&gt;Today it manifested as a stuck dashboard refresh and a Riverpod disposal error. On another screen it could just as easily show up as scroll position resets, flickering forms, or stateful children quietly losing context every time fresh data arrives.&lt;/p&gt;
&lt;p&gt;Flutter didn't really do anything wrong here. Riverpod definitely didn't. The app was just expressing an ambiguous state model with too much confidence.&lt;/p&gt;
&lt;p&gt;Anyway, the refresh spinner goes away now, which is honestly the kind of quiet victory I respect.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15672/the-refresh-spinner-that-wouldnt-let-go</guid>
<pubDate>Thu, 23 Apr 2026 17:14: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>Build Reliable Local Notifications in Flutter (Step-by-Step)</title>
<link>https://coderlegion.com/15350/build-reliable-local-notifications-in-flutter-step-by-step</link>
<description>&lt;p&gt;&lt;em&gt;Companion post to &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mxomasuku.com/blog/how-i-built-the-long-game-notification-system-a-journey-into-notifications-and-behavioral-engineering&quot;&gt;How I Built the Long Game Notification System&lt;/a&gt;. This is the walkthrough. If you want the thinking behind the decisions, read that first. If you just want the recipe, you're in the right place.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-re-building&quot;&gt;What You're Building&lt;/h3&gt;
&lt;p&gt;A notification system where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Notifications fire at the &lt;strong&gt;exact time&lt;/strong&gt; you set, every day&lt;/li&gt;
&lt;li&gt;They work when the phone is &lt;strong&gt;locked&lt;/strong&gt;, the app is &lt;strong&gt;killed&lt;/strong&gt;, or the device is in &lt;strong&gt;Doze mode&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Notification content is &lt;strong&gt;personalized&lt;/strong&gt; with fresh data on every app open&lt;/li&gt;
&lt;li&gt;The entire system runs &lt;strong&gt;locally&lt;/strong&gt; — no push server, no Cloud Functions, zero delivery cost&lt;/li&gt;
&lt;li&gt;Pomodoro-style one-shot alarms fire on time even when the app is suspended&lt;/li&gt;
&lt;li&gt;Notifications &lt;strong&gt;survive Samsung, Xiaomi, and Huawei battery killers&lt;/strong&gt; without FCM&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-you-re-not-using&quot;&gt;What You're NOT Using&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;workmanager&lt;/code&gt; — the OS throttles background tasks. Your 15-minute poll becomes hours.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Dart Timer.periodic&lt;/code&gt; for delivery — dies the moment the phone locks.&lt;/li&gt;
&lt;li&gt;Firebase Cloud Messaging — overkill for local, user-specific scheduling.&lt;/li&gt;
&lt;li&gt;Background Fetch (iOS) — Apple gives you 0–2 executions per day if you're lucky.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-dependencies&quot;&gt;Step 1: Dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# pubspec.yaml
dependencies:
  flutter_local_notifications: ^18.0.1
  timezone: ^0.10.0
  flutter_timezone: ^3.0.1
  permission_handler: ^11.3.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. Four packages. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The notification plugin handles scheduling. &lt;/li&gt;
&lt;li&gt;The timezone packages ensure your 8:00 AM means 8:00 AM in Johannesburg, not UTC. &lt;/li&gt;
&lt;li&gt;The permission handler lets you request notification access on Android 13+ &lt;strong&gt;and&lt;/strong&gt; battery optimization exemption on Samsung/OEM devices.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-android-permissions&quot;&gt;Step 2: Android Permissions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;!-- android/app/src/main/AndroidManifest.xml --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.POST_NOTIFICATIONS&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.RECEIVE_BOOT_COMPLETED&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.SCHEDULE_EXACT_ALARM&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.USE_EXACT_ALARM&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.WAKE_LOCK&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What each one does:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Permission &lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Required on Android 13+ to show any notification at all&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Alarms survive device reboots&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Fires at the exact second, even in Doze mode (API ≤ 33)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;USE_EXACT_ALARM&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Guaranteed exact alarms on API 33+ — no user prompt needed, unlike &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; on API 34+&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Lets you show the system &quot;Allow unrestricted battery?&quot; dialog — critical for Samsung, Xiaomi, Huawei&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;WAKE_LOCK&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Keeps the CPU alive long enough to process the alarm and fire the notification&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; On Android 14+ (API 34), &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; isn't granted by default. &lt;code&gt;USE_EXACT_ALARM&lt;/code&gt; is a stronger alternative that's always granted for apps that declare it — but it may trigger Google Play review. Having both ensures maximum compatibility. Your code still needs the inexact fallback we'll cover in Step 7.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-initialize-on-app-start&quot;&gt;Step 3: Initialize on App Start&lt;/h3&gt;
&lt;p&gt;Before you can schedule anything, you need to initialize the timezone database and the notification plugin. This runs once in &lt;code&gt;main()&lt;/code&gt;, before &lt;code&gt;runApp()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // ... your other init code (Firebase, etc.)

  await NotificationService.init();
  runApp(const MyApp());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tzdata;
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:permission_handler/permission_handler.dart';

class NotificationService {
  static final FlutterLocalNotificationsPlugin _plugin =
      FlutterLocalNotificationsPlugin();

  static Future&amp;lt;void&amp;gt; init() async {
    // 1. Initialize the timezone database and detect the device's zone.
    //    Without this, all scheduled times are wrong.
    tzdata.initializeTimeZones();
    try {
      final tzName = await FlutterTimezone.getLocalTimezone();
      tz.setLocalLocation(tz.getLocation(tzName));
    } catch (_) {
      // Fallback: stays UTC if detection fails
    }

    // 2. Initialize the plugin.
    //    We don't request permissions here — that happens later,
    //    at a moment that makes sense in your UX flow.
    const androidSettings = AndroidInitializationSettings(
      '@mipmap/ic_launcher',
    );
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    await _plugin.initialize(
      const InitializationSettings(
        android: androidSettings,
        iOS: iosSettings,
      ),
      onDidReceiveNotificationResponse: _onNotificationTap,
      onDidReceiveBackgroundNotificationResponse: _onBackgroundTap,
    );
  }

  // Handle taps when the app is alive
  static void _onNotificationTap(NotificationResponse response) {
    // Navigate to the relevant screen, stop a timer, etc.
  }

  // Handle taps when the app was killed — must be top-level or static
  @pragma('vm:entry-point')
  static void _onBackgroundTap(NotificationResponse response) {
    // App relaunches — handle in your normal init flow
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why timezone matters:&lt;/strong&gt; If you schedule for &quot;8:00 AM&quot; without initializing timezones, the plugin may interpret that as UTC. In Johannesburg (UTC+2), that's 10:00 AM. In New York (UTC-5), that's 3:00 AM. Always initialize before any &lt;code&gt;zonedSchedule&lt;/code&gt; call.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-schedule-a-daily-repeating-notification&quot;&gt;Step 4: Schedule a Daily Repeating Notification&lt;/h3&gt;
&lt;p&gt;This is the core of the entire system. Three lines do the heavy lifting.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleDailyNotification({
  required int id,
  required String title,
  required String body,
  required int hour,
  required int minute,
}) async {
  await _plugin.zonedSchedule(
    id,                                           // Fixed ID per notification type
    title,
    body,
    _nextInstanceOfTime(hour, minute),            // Next occurrence of this time
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'your_channel_id',
        'Your Channel Name',
        channelDescription: 'What this channel is for',
        importance: Importance.high,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    matchDateTimeComponents: DateTimeComponents.time,    // ← THIS IS THE KEY
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three things that make it work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;exactAllowWhileIdle&lt;/code&gt;&lt;/strong&gt; — fires even in Doze mode. The OS wakes up just enough to deliver your notification.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;matchDateTimeComponents: DateTimeComponents.time&lt;/code&gt;&lt;/strong&gt; — tells the OS: &quot;repeat this every day at this hour:minute.&quot; You schedule it once. It fires every day. No background task. No polling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;_nextInstanceOfTime&lt;/code&gt;&lt;/strong&gt; — computes the next future occurrence of the target time.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
  final now = tz.TZDateTime.now(tz.local);
  var scheduled = tz.TZDateTime(
    tz.local,
    now.year,
    now.month,
    now.day,
    hour,
    minute,
  );
  // zonedSchedule requires a future datetime
  if (scheduled.isBefore(now)) {
    scheduled = scheduled.add(const Duration(days: 1));
  }
  return scheduled;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this helper exists:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; requires the initial fire time to be in the future. If it's 9:00 AM and you schedule for 8:00 AM, the helper pushes it to 8:00 AM tomorrow. The &lt;code&gt;matchDateTimeComponents&lt;/code&gt; flag handles every day after that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-critical-the-cancel-trap&quot;&gt;Step 5: CRITICAL: The Cancel Trap&lt;/h3&gt;
&lt;p&gt;==(This bit is annoying if not taken care of)==&lt;/p&gt;
&lt;p&gt;This is the bug that will silently break your notifications and you won't notice for days. I shipped it. It cost me a full day of missed notifications before I caught it.&lt;/p&gt;
&lt;h4 id=&quot;the-broken-pattern&quot;&gt;The broken pattern&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;//  DO NOT DO THIS
static Future&amp;lt;void&amp;gt; reschedule() async {
  await _plugin.cancel(id);                     // Destroys the repeating alarm
  await _plugin.zonedSchedule(id, ...);         // Creates a new one
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It looks correct. It's not. Here's what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User opens the app at &lt;strong&gt;9:00 AM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cancel(1)&lt;/code&gt; — destroys the existing repeating 8:00 AM alarm&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_nextInstanceOfTime(8, 0)&lt;/code&gt; — 8:00 AM today already passed, returns &lt;strong&gt;tomorrow 8:00 AM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zonedSchedule(1, ...)&lt;/code&gt; — schedules a new alarm starting tomorrow&lt;/li&gt;
&lt;li&gt;Tomorrow, user opens the app at 9:00 AM again → &lt;strong&gt;same thing&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The notification is perpetually pushed to &quot;tomorrow&quot; and &lt;strong&gt;never fires&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-correct-pattern&quot;&gt;The correct pattern&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Only cancel when the user disables the notification
static Future&amp;lt;void&amp;gt; schedule(bool enabled, int hour, int minute) async {
  if (!enabled) {
    await _plugin.cancel(id);    // User turned it off — remove the alarm
    return;
  }
  // Calling zonedSchedule with the same ID REPLACES the existing alarm
  // without resetting the repeat cycle. No cancel needed.
  await _plugin.zonedSchedule(id, ...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; with the same ID overwrites the previous alarm — it updates the title, body, and schedule time without destroying the repeat. You only need &lt;code&gt;cancel&lt;/code&gt; when you want the notification to stop entirely.&lt;/p&gt;
&lt;p&gt;This means you can safely call &lt;code&gt;rescheduleAll()&lt;/code&gt; on every app open to refresh notification content (e.g., &quot;You have 3 projects today&quot; becomes &quot;You have 4 projects today&quot;) without breaking delivery.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-refresh-content-on-app-open&quot;&gt;Step 6: Refresh Content on App Open&lt;/h3&gt;
&lt;p&gt;Notification bodies are frozen at schedule time. If the user adds a project at 11 PM, you want tomorrow's morning notification to include it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// main.dart — after init, before runApp
if (FirebaseAuth.instance.currentUser != null) {
  NotificationService.rescheduleAllNotifications().catchError((_) {});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rescheduleAllNotifications()&lt;/code&gt; fetches fresh data and calls &lt;code&gt;zonedSchedule&lt;/code&gt; for each enabled notification, overwriting the stale body. No &lt;code&gt;cancel&lt;/code&gt; — just overwrite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two things to guard against:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth check&lt;/strong&gt; — if your notification content depends on user-specific data (Firestore queries), and no user is signed in, the query will hang. This blocks &lt;code&gt;main()&lt;/code&gt; and your app never starts. Gate it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fire-and-forget&lt;/strong&gt; — use &lt;code&gt;.catchError((_) {})&lt;/code&gt;. If the reschedule fails (offline, Firestore timeout), the previously scheduled alarm still fires with yesterday's content. That's better than crashing on startup.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-handle-android-14-exact-alarm-restriction&quot;&gt;Step 7: Handle Android 14+ Exact Alarm Restriction&lt;/h3&gt;
&lt;p&gt;Android 14 changed the rules. &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; is no longer auto-granted. If your app calls &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; without the permission, it throws.&lt;/p&gt;
&lt;p&gt;The graceful fallback:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleDailyNotification({
  required int id,
  required String title,
  required String body,
  required int hour,
  required int minute,
}) async {
  final scheduledTime = _nextInstanceOfTime(hour, minute);
  final details = /* your NotificationDetails */;

  try {
    // Try exact first — best experience
    await _plugin.zonedSchedule(
      id, title, body, scheduledTime, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,
    );
  } catch (e) {
    // Exact alarm not permitted — fall back to inexact
    // Delivery may drift by ~10 minutes, but the notification still fires
    await _plugin.zonedSchedule(
      id, title, body, scheduledTime, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For most notifications, a 10-minute window is fine. &quot;Your day is 63% gone&quot; at 2:10 PM instead of 2:00 PM doesn't break the experience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Apply this same pattern to one-shot alarms too.&lt;/strong&gt; Your pomodoro alarm scheduler should have the same try/catch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; schedulePhaseAlarm({
  required int durationMinutes,
  required NotificationDetails details,
}) async {
  final fireAt = tz.TZDateTime.now(tz.local)
      .add(Duration(minutes: durationMinutes));

  try {
    await _plugin.zonedSchedule(
      alarmId, title, message, fireAt, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    );
  } catch (_) {
    // Fall back to inexact — still fires, may drift ~5-10 min
    try {
      await _plugin.zonedSchedule(
        alarmId, title, message, fireAt, details,
        uiLocalNotificationDateInterpretation:
            UILocalNotificationDateInterpretation.absoluteTime,
        androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      );
    } catch (e) {
      print('[Notifications] Failed to schedule alarm: $e');
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-one-shot-alarms-pomodoro-timers-&quot;&gt;Step 8: One-Shot Alarms (Pomodoro, Timers)&lt;/h3&gt;
&lt;p&gt;Daily repeating alarms cover most cases. But sometimes you need a notification that fires once at a specific future time — like when a 25-minute pomodoro focus session ends.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleOneShot({
  required int id,
  required String title,
  required String body,
  required int minutesFromNow,
  required NotificationDetails details,
}) async {
  final fireAt = tz.TZDateTime.now(tz.local)
      .add(Duration(minutes: minutesFromNow));

  await _plugin.zonedSchedule(
    id,
    title,
    body,
    fireAt,
    details,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    // NO matchDateTimeComponents — this fires once, doesn't repeat
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why you need this for pomodoro:&lt;/strong&gt; A Dart &lt;code&gt;Timer.periodic&lt;/code&gt; runs in your app's process. When the user locks their phone, the OS suspends the app within ~30 seconds. Your timer stops ticking. The phase ends, and — silence. The bell only rings when they unlock the phone and the app resumes.&lt;/p&gt;
&lt;p&gt;An OS alarm doesn't care about your app's lifecycle. It fires regardless.&lt;/p&gt;
&lt;h4 id=&quot;making-one-shot-alarms-alarm-grade&quot;&gt;Making one-shot alarms alarm-grade&lt;/h4&gt;
&lt;p&gt;A standard &lt;code&gt;Importance.high&lt;/code&gt; notification won't cut it on Samsung, Xiaomi, or Huawei devices. These OEMs aggressively kill background processes and suppress notifications they deem non-essential. You need to make your notification look like an alarm clock to the OS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'dart:typed_data';

NotificationDetails _alarmGradeDetails({required bool isWorkPhase}) {
  final soundFile = isWorkPhase ? 'bell_focus' : 'bell_break';
  final channelId = isWorkPhase ? 'focus_bell' : 'break_bell';
  final channelName = isWorkPhase ? 'Focus Bell' : 'Break Bell';

  return NotificationDetails(
    android: AndroidNotificationDetails(
      channelId,
      channelName,
      channelDescription: 'Pomodoro phase transition',
      importance: Importance.max,                              // Maximum priority
      priority: Priority.max,
      sound: RawResourceAndroidNotificationSound(soundFile),
      playSound: true,
      fullScreenIntent: true,                                  // Wakes the screen
      category: AndroidNotificationCategory.alarm,             // Treated like an alarm
      visibility: NotificationVisibility.public,               // Shows on lock screen
      enableVibration: true,
      vibrationPattern: Int64List.fromList([0, 400, 200, 400]),
    ),
    iOS: DarwinNotificationDetails(
      presentAlert: true,
      presentSound: true,
      sound: '$soundFile.wav',
      interruptionLevel: InterruptionLevel.timeSensitive,      // Breaks through Focus
    ),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What each flag does:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Flag &lt;/th&gt;&lt;th&gt;Effect&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;importance: Importance.max&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Heads-up notification — appears at the top of the screen&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;fullScreenIntent: true&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Wakes the screen and shows the notification even when locked. This is what alarm clock apps use.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;category: AndroidNotificationCategory.alarm&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Tells the OS this is time-critical. Samsung's battery manager respects this category.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;visibility: NotificationVisibility.public&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Content visible on the lock screen without unlocking&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;vibrationPattern&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Custom vibration so the user physically feels it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;interruptionLevel: InterruptionLevel.timeSensitive&lt;/code&gt; &lt;/td&gt;&lt;td&gt;iOS 15+: breaks through Focus mode&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;This is the difference between a notification that works on your desk and one that works in your pocket.&lt;/strong&gt; Standard &lt;code&gt;Importance.high&lt;/code&gt; gets silently suppressed by Samsung's battery manager. &lt;code&gt;Importance.max&lt;/code&gt; + &lt;code&gt;fullScreenIntent&lt;/code&gt; + &lt;code&gt;category: alarm&lt;/code&gt; does not.&lt;/p&gt;
&lt;h4 id=&quot;the-dual-delivery-pattern&quot;&gt;The dual-delivery pattern&lt;/h4&gt;
&lt;p&gt;When the app is in the foreground, the Dart timer catches the transition first (instant feedback). When the phone is locked, the OS alarm delivers it. To avoid the user hearing two bells:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// In your timer tick (foreground path)
void _onPomodoroPhaseEnd() {
  _plugin.cancel(alarmId);            // Cancel the OS alarm (Dart beat it)
  _plugin.show(displayId, ...);       // Show the notification immediately
  _scheduleNextPhaseAlarm();          // Schedule for the next phase
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use separate IDs for the scheduled alarm and the instant notification if you want, or the same ID if you want one to replace the other. Either way, the user sees exactly one notification.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-weekly-repeating-notifications&quot;&gt;Step 9: Weekly Repeating Notifications&lt;/h3&gt;
&lt;p&gt;For notifications that fire on specific weekdays — like a project reminder every Monday, Wednesday, and Friday at 6:00 PM.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleWeekly({
  required int id,
  required String title,
  required String body,
  required int weekday,    // 1 = Monday, 7 = Sunday
  required int hour,
  required int minute,
}) async {
  await _plugin.zonedSchedule(
    id,
    title,
    body,
    _nextInstanceOfWeekdayTime(weekday, hour, minute),
    /* notificationDetails */,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
    matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime,  // ← Weekly
  );
}

static tz.TZDateTime _nextInstanceOfWeekdayTime(
  int weekday, int hour, int minute,
) {
  final now = tz.TZDateTime.now(tz.local);
  var scheduled = tz.TZDateTime(
    tz.local, now.year, now.month, now.day, hour, minute,
  );
  // Advance to the target weekday
  while (scheduled.weekday != weekday) {
    scheduled = scheduled.add(const Duration(days: 1));
  }
  // If it's already passed this week, push to next week
  if (scheduled.isBefore(now)) {
    scheduled = scheduled.add(const Duration(days: 7));
  }
  return scheduled;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Each weekday needs its own notification ID. If you want reminders on Monday, Wednesday, and Friday, that's three separate &lt;code&gt;zonedSchedule&lt;/code&gt; calls with three different IDs. When removing the reminder, cancel all of them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Generating unique IDs per project + weekday
final notifId = baseId + projectId.hashCode.abs() % 900 + weekday;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-surviving-samsung-battery-optimization&quot;&gt;Step 10: Surviving Samsung Battery Optimization&lt;/h3&gt;
&lt;p&gt;This is the step most Flutter notification tutorials skip, and it's why your notifications work perfectly during development but fail silently in production.&lt;/p&gt;
&lt;h4 id=&quot;the-problem-2&quot;&gt;The problem&lt;/h4&gt;
&lt;p&gt;Samsung, Xiaomi, Huawei, OnePlus, and most Chinese OEMs add an aggressive battery optimization layer &lt;strong&gt;on top of&lt;/strong&gt; Android's standard Doze mode. Even if your alarm is correctly s&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15350/build-reliable-local-notifications-in-flutter-step-by-step</guid>
<pubDate>Tue, 21 Apr 2026 01:03:01 +0000</pubDate>
</item>
<item>
<title>Understanding Flutter Method Channels Through a Real Production Bug</title>
<link>https://coderlegion.com/14995/understanding-flutter-method-channels-through-a-real-production-bug</link>
<description>&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Flutter is amazing—until it isn’t.&lt;/p&gt;
&lt;p&gt;Most of the time, you can build entire apps without ever touching native code. But the moment you hit a platform limitation, things get… interesting.&lt;/p&gt;
&lt;p&gt;This blog isn’t just about Method Channels.&lt;/p&gt;
&lt;p&gt;It’s about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When Flutter abstractions break down&lt;/li&gt;
&lt;li&gt;How to take control using native Android&lt;/li&gt;
&lt;li&gt;And how to think about Flutter ↔ Native communication like a system, not a hack&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Funny enough, this blog comes from a real bug I hit while building my app &lt;em&gt;Anahad&lt;/em&gt; — a saadhna app that plays mantra chants.&lt;/p&gt;
&lt;p&gt;And yeah… it broke in a very weird way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-real-problem-why-this-blog-exists-&quot;&gt;The Real Problem (Why This Blog Exists)&lt;/h3&gt;
&lt;p&gt;I was building a saadhna app that plays mantra chants on loop.&lt;/p&gt;
&lt;p&gt;Simple requirement:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Play audio  &lt;/li&gt;
&lt;li&gt;Loop it continuously  &lt;/li&gt;
&lt;li&gt;Run in the background (screen off)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;audio_service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;just_audio&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;just_audio_background&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything worked perfectly…&lt;/p&gt;
&lt;p&gt;Until it didn’t.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-bug&quot;&gt;The Bug&lt;/h4&gt;
&lt;p&gt;After ~940–960 loops on Android (specifically Android 12+):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Playback would stop silently  &lt;/li&gt;
&lt;li&gt;No crash  &lt;/li&gt;
&lt;li&gt;No exception  &lt;/li&gt;
&lt;li&gt;Only when the screen was off  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disabling battery optimization  &lt;/li&gt;
&lt;li&gt;Proper background setup  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Still broke.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-weird-part&quot;&gt;The Weird Part&lt;/h4&gt;
&lt;p&gt;It &lt;em&gt;consistently&lt;/em&gt; failed around the same loop range (~940–960).&lt;/p&gt;
&lt;p&gt;Not 100. Not 500. Not random.&lt;/p&gt;
&lt;p&gt;That kind of consistency usually means:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Something deeper is going on — not just app-level logic.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;at-this-point-two-choices&quot;&gt;At This Point, Two Choices&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Keep debugging blindly inside Flutter  &lt;/li&gt;
&lt;li&gt;Drop down to native Android and take control  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I chose option 2, cz i never interacted with native code and wanted to explore it&lt;/p&gt;
&lt;p&gt;Fun part, i had an android 11 device which wasn't having this bug at all, tested on other android 11 devices as well&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-flutter-isn-t-always-enough&quot;&gt;Why Flutter Isn’t Always Enough&lt;/h3&gt;
&lt;p&gt;Flutter is great, but it abstracts away platform details.&lt;/p&gt;
&lt;p&gt;That’s usually good—until you need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Deep lifecycle control  &lt;/li&gt;
&lt;li&gt;System-level integrations  &lt;/li&gt;
&lt;li&gt;Debug visibility into platform behavior  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Common cases where native is needed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Background services  &lt;/li&gt;
&lt;li&gt;Media playback control  &lt;/li&gt;
&lt;li&gt;Hardware interaction  &lt;/li&gt;
&lt;li&gt;Platform-specific APIs  &lt;/li&gt;
&lt;li&gt;Performance-critical logic  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In my case:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I needed more control over how Android handles long-running audio playback. Just to be absolutely sure that it was not the issue of the package that i was using&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understanding-method-channels-the-right-mental-model-&quot;&gt;Understanding Method Channels (The Right Mental Model)&lt;/h3&gt;
&lt;p&gt;Before code, get this clear:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Dart is the client. Native Android is the server.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When you write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;platform.invokeMethod(&quot;play&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You’re essentially doing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dart → sends request&lt;/li&gt;
&lt;li&gt;Android → receives it&lt;/li&gt;
&lt;li&gt;Executes native logic&lt;/li&gt;
&lt;li&gt;Returns response&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of it like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;An internal API call inside your app&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;No HTTP. No network. Just a binary bridge.&lt;/p&gt;
&lt;p&gt;Once you understand this, Method Channels stop feeling “magical”.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architecture-overview&quot;&gt;Architecture Overview&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Flutter (Dart)
├── UI Layer
├── Service Layer (PlaybackService.dart)
└── State Management

        ↓↓↓ Method Channel ↓↓↓

Android (Kotlin)
├── MethodChannel Handler
├── PlaybackService (MediaSessionService)
└── ExoPlayer
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;flow-step-by-step-&quot;&gt;Flow (Step-by-Step)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;User taps &quot;Play&quot;&lt;/li&gt;
&lt;li&gt;Dart calls &lt;code&gt;invokeMethod(&quot;play&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;MethodChannel sends message&lt;/li&gt;
&lt;li&gt;Kotlin receives it&lt;/li&gt;
&lt;li&gt;Native service executes logic&lt;/li&gt;
&lt;li&gt;Result is sent back&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-native-side-android-kotlin-&quot;&gt;The Native Side: Android (Kotlin)&lt;/h3&gt;
&lt;h4 id=&quot;why-mediasessionservice-&quot;&gt;Why MediaSessionService?&lt;/h4&gt;
&lt;p&gt;Instead of a basic service, we use:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;MediaSessionService&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Because it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Integrates with system controls (lock screen, notifications)&lt;/li&gt;
&lt;li&gt;Handles background playback more reliably&lt;/li&gt;
&lt;li&gt;Works with audio focus and lifecycle events&lt;/li&gt;
&lt;li&gt;Plays nicely with the Android system&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;playbackservice-kt&quot;&gt;PlaybackService.kt&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class PlaybackService : MediaSessionService() {
    private var mediaSession: MediaSession? = null

    override fun onCreate() {
        super.onCreate()

        val audioAttributes =
            AudioAttributes.Builder()
                .setUsage(C.USAGE_MEDIA)
                .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
                .build()

        val player =
            ExoPlayer.Builder(this)
                .setAudioAttributes(audioAttributes, true)
                .setHandleAudioBecomingNoisy(true)
                .setWakeMode(C.WAKE_MODE_NETWORK)
                .build()

        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) {
                Log.e(&quot;PlaybackService&quot;, &quot;Error: ${error.message}&quot;, error)
            }
        })

        val callback = object : MediaSession.Callback {}

        mediaSession = MediaSession.Builder(this, player)
            .setCallback(callback)
            .build()
    }

    override fun onDestroy() {
        mediaSession?.run {
            player.release()
            release()
            mediaSession = null
        }
        super.onDestroy()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-flutter-side-dart&quot;&gt;The Flutter Side: Dart&lt;/h3&gt;
&lt;h4 id=&quot;service-wrapper&quot;&gt;Service Wrapper&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class PlaybackService {
  static const platform =
      MethodChannel('com.theahambrahmasmi.anahad/playback');

  static Future&amp;lt;void&amp;gt; play(String url) async {
    await platform.invokeMethod('play', {'url': url});
  }

  static Future&amp;lt;void&amp;gt; pause() async {
    await platform.invokeMethod('pause');
  }

  static Future&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt; getStatus() async {
    final result = await platform.invokeMethod('getStatus');
    return Map&amp;lt;String, dynamic&amp;gt;.from(result);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;kotlin-method-channel-handler&quot;&gt;Kotlin: Method Channel Handler&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class PlaybackMethodHandler(private val context: Context) {

    fun setupChannel(flutterEngine: FlutterEngine) {
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            &quot;com.theahambrahmasmi.anahad/playback&quot;
        ).setMethodCallHandler { call, result -&amp;gt;

            when (call.method) {
                &quot;play&quot; -&amp;gt; {
                    val url = call.argument&amp;lt;String&amp;gt;(&quot;url&quot;)
                    val intent = Intent(context, PlaybackService::class.java)
                    intent.putExtra(&quot;audio_url&quot;, url)
                    context.startService(intent)
                    result.success(null)
                }

                &quot;pause&quot; -&amp;gt; {
                    val intent = Intent(context, PlaybackService::class.java)
                    context.stopService(intent)
                    result.success(null)
                }

                else -&amp;gt; result.notImplemented()
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-insight-the-looping-problem&quot;&gt;Real-World Insight: The Looping Problem&lt;/h3&gt;
&lt;p&gt;If you're building:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Meditation apps&lt;/li&gt;
&lt;li&gt;Chanting apps&lt;/li&gt;
&lt;li&gt;Ambient loop apps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Be careful.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Android may silently stop long-running playback after extended loops.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Even with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Battery optimization disabled&lt;/li&gt;
&lt;li&gt;Background services configured&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Test aggressively:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;500+ loops&lt;/li&gt;
&lt;li&gt;Screen-off playback&lt;/li&gt;
&lt;li&gt;Different OEM devices&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is not just Flutter—it’s often &lt;strong&gt;Android system behavior&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-the-root-cause-&quot;&gt;What About The Root Cause?&lt;/h3&gt;
&lt;p&gt;Short answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I don’t fully know yet.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And yeah — that’s intentional honesty.&lt;/p&gt;
&lt;p&gt;What I &lt;em&gt;do&lt;/em&gt; know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It only happens on &lt;strong&gt;Android 12+&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;It triggers after long-running background playback&lt;/li&gt;
&lt;li&gt;It happens silently (no crash, no logs)&lt;/li&gt;
&lt;li&gt;It correlates with a high loop count (~950)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;current-hypotheses-&quot;&gt;Current Hypotheses:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Doze / background execution limits&lt;/li&gt;
&lt;li&gt;Audio focus or media session interruption&lt;/li&gt;
&lt;li&gt;ExoPlayer internal behavior after prolonged looping&lt;/li&gt;
&lt;li&gt;OEM-level process killing&lt;/li&gt;
&lt;li&gt;Resource exhaustion over time&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;we-ll-be-updating-this&quot;&gt;We’ll Be Updating This&lt;/h3&gt;
&lt;p&gt;Once I dig deeper and find the exact root cause, I’ll update this blog with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What exactly broke&lt;/li&gt;
&lt;li&gt;Why Android 12+ behaves this way&lt;/li&gt;
&lt;li&gt;The proper fix (not just experimental ones)&lt;/li&gt;
&lt;li&gt;What you should do in production&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because honestly?&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Debugging something that breaks at the OS level is messy… and kinda fun.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;I eventually moved back to &lt;code&gt;just_audio&lt;/code&gt; because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The problematic flow was no longer needed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So yeah…&lt;/p&gt;
&lt;p&gt;This experimental fix wasn’t used in production.&lt;/p&gt;
&lt;p&gt;But it gave me something way more valuable:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A deeper understanding of how Flutter actually works under the hood.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And honestly?&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I’m not perfect. I didn’t fully solve it (yet).&lt;br&gt;
But now I know one more thing than I did before.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And that’s what matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Flutter is great, but not enough for everything&lt;/li&gt;
&lt;li&gt;Method Channels = internal RPC bridge&lt;/li&gt;
&lt;li&gt;Native gives you control Flutter abstracts away&lt;/li&gt;
&lt;li&gt;Android background behavior can be unpredictable&lt;/li&gt;
&lt;li&gt;Sometimes… you won’t have all the answers immediately&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;If you're building something serious:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Don’t just rely on abstractions. Understand what’s underneath.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That’s where real engineering begins.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/14995/understanding-flutter-method-channels-through-a-real-production-bug</guid>
<pubDate>Thu, 16 Apr 2026 15:48:54 +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>Building OneRule: A Technical Deep Dive into an Offline Password Manager with Flutter, SQLCipher, and AES-GCM</title>
<link>https://coderlegion.com/14676/building-onerule-technical-deep-dive-into-offline-password-manager-with-flutter-sqlcipher</link>
<description>&lt;p&gt;Password managers often get discussed at product level: sync, UX, pricing, recovery, browser extensions.&lt;/p&gt;
&lt;p&gt;What interested me more in OneRule was lower in stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;what &quot;offline-first&quot; actually means in implementation&lt;/li&gt;
&lt;li&gt;how local key derivation is handled&lt;/li&gt;
&lt;li&gt;where encryption boundaries sit&lt;/li&gt;
&lt;li&gt;how Flutter and native Android divide responsibility&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OneRule is an open-source Android password manager built with Flutter. It does not require an account or backend for core vault operations. The interesting part is not only that it works offline, but that the architecture is intentionally shaped around that constraint.&lt;/p&gt;
&lt;p&gt;This post is a technical walkthrough of current implementation: storage model, key flow, backup format, panic mode behavior, and Android Autofill boundary.&lt;/p&gt;
&lt;h3 id=&quot;architectural-shape&quot;&gt;Architectural Shape&lt;/h3&gt;
&lt;p&gt;At code level, OneRule follows a fairly direct layered structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flutter UI for screens, forms, and interaction flows&lt;/li&gt;
&lt;li&gt;Provider-based state for app-level reactive data&lt;/li&gt;
&lt;li&gt;service and facade classes for storage, crypto, backup, auth, clipboard, and platform integration&lt;/li&gt;
&lt;li&gt;native Android code where platform APIs are necessary, especially Autofill&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practical terms, the flow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UI/screens
  -&amp;gt; Provider state
    -&amp;gt; service/facade layer
      -&amp;gt; SQLCipher / secure storage / crypto / platform channels
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is not an especially exotic architecture, but it fits security-sensitive mobile software well. UI code stays mostly unaware of encryption details. Providers coordinate visible app state. Services own the mechanics of vault initialization, field encryption, backup creation, and unlock behavior.&lt;/p&gt;
&lt;p&gt;For a Flutter app, that separation matters because it reduces chances that sensitive logic leaks into widget code and becomes harder to reason about.&lt;/p&gt;
&lt;p&gt;The startup path is also explicit about local protections. On launch, the app initializes error handling and reminder services, then enables screen-capture protections before rendering UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await LocalLogService.instance.initializeGlobalErrorHandlers();
  await BackupReminderService.instance.initialize();

  if (Platform.isAndroid || Platform.isIOS) {
    await ScreenProtector.preventScreenshotOn();
    await ScreenProtector.protectDataLeakageOn();
    await ScreenProtector.protectDataLeakageWithBlur();
  }

  runApp(/* providers + app */);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is small, but it shows something important about OneRule: operational security controls are part of app bootstrap, not optional polish added later.&lt;/p&gt;
&lt;h3 id=&quot;data-at-rest-model&quot;&gt;Data-at-Rest Model&lt;/h3&gt;
&lt;p&gt;The vault is not stored as plaintext records in local SQLite.&lt;/p&gt;
&lt;p&gt;OneRule uses &lt;strong&gt;SQLCipher-backed SQLite&lt;/strong&gt; as the primary vault store. The database file is encrypted at rest, and the SQLCipher password is derived from the active in-memory session key.&lt;/p&gt;
&lt;p&gt;But the design does not stop there.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;password&lt;/code&gt; field is also encrypted again at application level using &lt;strong&gt;AES-256-GCM&lt;/strong&gt;. So the current storage model is effectively:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;encrypted database file via SQLCipher&lt;/li&gt;
&lt;li&gt;encrypted sensitive field payloads inside that database&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That second layer is important. It means the most sensitive credential field is stored and serialized as an authenticated encrypted envelope, not only as a row protected by database-level encryption. When the Flutter app needs to render or analyze entries, it decrypts that field at read time.&lt;/p&gt;
&lt;p&gt;The current field payload format is versioned and structured like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;or1:v2:gcm:&amp;lt;nonce_b64url&amp;gt;:&amp;lt;cipherText_b64url&amp;gt;:&amp;lt;tag_b64url&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using a versioned envelope instead of opaque bytes makes future migrations much easier. It also gives the app a clean way to distinguish between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;current AES-GCM records&lt;/li&gt;
&lt;li&gt;legacy AES-CBC records&lt;/li&gt;
&lt;li&gt;pre-envelope data from older versions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That matters because OneRule includes migration logic and does not assume the vault will always start in latest format.&lt;/p&gt;
&lt;p&gt;For backups, the code takes the same approach: explicit envelope, explicit metadata, explicit version.&lt;/p&gt;
&lt;h3 id=&quot;key-derivation-and-session-model&quot;&gt;Key Derivation and Session Model&lt;/h3&gt;
&lt;p&gt;The master PIN is not used directly as an encryption key.&lt;/p&gt;
&lt;p&gt;Instead, OneRule derives key material with &lt;strong&gt;PBKDF2-HMAC-SHA256&lt;/strong&gt;. Current implementation uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;100,000 iterations for interactive PIN unlock/session derivation&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Backup encryption uses a separate derivation profile:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;200,000 iterations&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salt embedded in backup payload&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That separation is a good design choice. Unlock flows need acceptable latency on-device. Backup export and import can afford a higher work factor because they are less frequent and less latency-sensitive.&lt;/p&gt;
&lt;p&gt;The session model is also worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the active vault session key is kept in memory&lt;/li&gt;
&lt;li&gt;SQLCipher password is derived from that key&lt;/li&gt;
&lt;li&gt;secure storage holds salts, verifiers, and biometric-restorable material&lt;/li&gt;
&lt;li&gt;session key is cleared on logout and lock paths&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the app has a fairly explicit distinction between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;persistent verification or recovery metadata&lt;/li&gt;
&lt;li&gt;active decrypted session state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is small but important. A lot of local-first security design is really state-lifecycle design.&lt;/p&gt;
&lt;p&gt;Backup derivation path is straightforward and readable in code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;SecretKey&amp;gt; _deriveKey(String passphrase, List&amp;lt;int&amp;gt; salt) async {
  final pbkdf2 = Pbkdf2(
    macAlgorithm: Hmac.sha256(),
    iterations: _pbkdf2Iterations,
    bits: 256,
  );
  return pbkdf2.deriveKey(
    secretKey: SecretKey(utf8.encode(passphrase)),
    nonce: salt,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the payload is encrypted with AES-GCM using fresh salt and nonce:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final salt = _randomBytes(16);
final nonce = _randomBytes(12);
final key = await _deriveKey(passphrase, salt);

final secretBox = await _gcm.encrypt(
  utf8.encode(plaintext),
  secretKey: key,
  nonce: nonce,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-sqlcipher-plus-field-level-aes-gcm-&quot;&gt;Why SQLCipher Plus Field-Level AES-GCM?&lt;/h3&gt;
&lt;p&gt;A reasonable question is: if SQLCipher already encrypts database file at rest, why encrypt the password field again?&lt;/p&gt;
&lt;p&gt;The answer is separation of concerns.&lt;/p&gt;
&lt;p&gt;SQLCipher protects the database as a container. Field-level AES-GCM protects the most sensitive payloads as application-managed cryptographic objects with explicit integrity tags and versioned envelopes.&lt;/p&gt;
&lt;p&gt;That gives OneRule a few advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;clean migration path from old formats to new ones&lt;/li&gt;
&lt;li&gt;explicit tamper detection at field level&lt;/li&gt;
&lt;li&gt;easier reuse of encrypted credential payloads outside normal Flutter list rendering&lt;/li&gt;
&lt;li&gt;tighter control over what can be serialized or handed to native layers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Android Autofill integration benefits directly from this. More on that later.&lt;/p&gt;
&lt;h3 id=&quot;vault-migration-strategy&quot;&gt;Vault Migration Strategy&lt;/h3&gt;
&lt;p&gt;OneRule still contains migration support for older storage models.&lt;/p&gt;
&lt;p&gt;There are two migration stories in current codebase:&lt;/p&gt;
&lt;h4 id=&quot;1-legacy-vault-backend-migration&quot;&gt;1. Legacy vault backend migration&lt;/h4&gt;
&lt;p&gt;Older vault data could exist in Hive-based storage. Current architecture uses SQLCipher, and the app includes a one-time migration reader that imports legacy records into encrypted SQLite, then removes old box data and marks migration complete.&lt;/p&gt;
&lt;h4 id=&quot;2-legacy-cipher-format-migration&quot;&gt;2. Legacy cipher format migration&lt;/h4&gt;
&lt;p&gt;Password payloads and backup payloads have moved from older layouts into AES-GCM-based envelopes.&lt;/p&gt;
&lt;p&gt;For vault records, the app can still read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;legacy CBC envelope format&lt;/li&gt;
&lt;li&gt;older marker-based GCM payloads&lt;/li&gt;
&lt;li&gt;older plaintext-style legacy values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On unlock, rows are scanned and migrated into current &lt;code&gt;or1:v2:gcm&lt;/code&gt; format. Migration is designed to be non-lossy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;read legacy payload&lt;/li&gt;
&lt;li&gt;decrypt or parse&lt;/li&gt;
&lt;li&gt;re-encrypt in latest format&lt;/li&gt;
&lt;li&gt;persist migrated value&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If crypto validation fails, migration hard-fails rather than overwriting questionable data.&lt;/p&gt;
&lt;p&gt;That is exactly what you want in security migration code. Silent best-effort conversion is dangerous when integrity matters.&lt;/p&gt;
&lt;h3 id=&quot;backup-format-and-cryptographic-container&quot;&gt;Backup Format and Cryptographic Container&lt;/h3&gt;
&lt;p&gt;Offline-first apps still need a portability story. Otherwise &quot;no sync&quot; quickly turns into &quot;single-device risk.&quot;&lt;/p&gt;
&lt;p&gt;OneRule handles that with encrypted backup export and import. Current export format is a JSON-based &lt;code&gt;.enc&lt;/code&gt; container with schema version &lt;code&gt;v: 3&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The backup pipeline looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;collect vault records&lt;/li&gt;
&lt;li&gt;serialize plaintext JSON&lt;/li&gt;
&lt;li&gt;derive encryption key from backup passphrase using PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;encrypt with AES-256-GCM using random nonce&lt;/li&gt;
&lt;li&gt;emit structured JSON container with metadata&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The container includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;KDF metadata&lt;/li&gt;
&lt;li&gt;cipher metadata&lt;/li&gt;
&lt;li&gt;salt&lt;/li&gt;
&lt;li&gt;authenticated encryption envelope&lt;/li&gt;
&lt;li&gt;creation timestamp&lt;/li&gt;
&lt;li&gt;item count&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a much better design than &quot;just encrypt some bytes and hope future versions remember how.&quot; Explicit metadata makes forward migration, compatibility handling, and debugging safer.&lt;/p&gt;
&lt;p&gt;Current backup writer emits a structured object like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;return {
  'v': _backupSchemaVersion,
  'envelope': {
    'format': 'onerule-backup',
    'version': 'v3',
    'algorithm': _gcmAlgorithmName,
    'nonce': base64UrlEncode(secretBox.nonce),
    'cipherText': base64UrlEncode(secretBox.cipherText),
    'tag': base64UrlEncode(secretBox.mac.bytes),
  },
  'salt': base64UrlEncode(salt),
  'kdf': {
    'algorithm': 'PBKDF2-HMAC-SHA256',
    'iterations': _pbkdf2Iterations,
    'keyBits': 256,
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That snippet captures design philosophy well: crypto parameters travel with container, not only with developer memory.&lt;/p&gt;
&lt;p&gt;Import path is also backward-compatible with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;current &lt;code&gt;.enc&lt;/code&gt; encrypted backups&lt;/li&gt;
&lt;li&gt;legacy &lt;code&gt;.onerule&lt;/code&gt; extension&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 1&lt;/code&gt;, including legacy CBC layout&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Again, the pattern here is versioned crypto container plus explicit readers, not hidden magic.&lt;/p&gt;
&lt;h3 id=&quot;panic-pin-and-privacy-flow&quot;&gt;Panic PIN and Privacy Flow&lt;/h3&gt;
&lt;p&gt;Panic mode is one of the more interesting product decisions because it crosses UX, threat modeling, and implementation details.&lt;/p&gt;
&lt;p&gt;OneRule supports a separate Panic PIN verifier. When this flow is used, the app avoids exposing the real vault and enters a privacy-focused decoy mode.&lt;/p&gt;
&lt;p&gt;There are two implementation details worth noting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;provider state sets panic mode and suppresses real vault data&lt;/li&gt;
&lt;li&gt;home screen renders decoy credential entries for that mode&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means panic mode is not implemented as &quot;delete everything&quot; or &quot;rename list to empty.&quot; It is an explicit alternate UI and state flow.&lt;/p&gt;
&lt;p&gt;This is a subtle but meaningful distinction. Security-sensitive UX should not depend on the user remembering whether a blank screen is real, broken, or intentional.&lt;/p&gt;
&lt;p&gt;The app also warns users that panic mode is a decoy flow and can be confusing if triggered accidentally. That kind of warning is good product engineering. Safety features that are easy to misunderstand become liabilities.&lt;/p&gt;
&lt;h3 id=&quot;android-autofill-boundary&quot;&gt;Android Autofill Boundary&lt;/h3&gt;
&lt;p&gt;Password managers live or die on ergonomics, and Android Autofill is one place where architecture decisions show up clearly.&lt;/p&gt;
&lt;p&gt;OneRule uses a native Android &lt;code&gt;AutofillService&lt;/code&gt; for API 26+ and bridges it from Flutter over a platform channel. The relevant boundary is important:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flutter owns normal vault UX and decrypted entry access after unlock&lt;/li&gt;
&lt;li&gt;Flutter syncs an &lt;strong&gt;encrypted credential snapshot&lt;/strong&gt; to native storage&lt;/li&gt;
&lt;li&gt;Flutter also provides a session key to native layer for active unlock window&lt;/li&gt;
&lt;li&gt;native Autofill service performs decryption only when it needs to fill a request&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means Autofill is not backed by a permanently stored plaintext cache. Instead, encrypted envelopes are synced to native side, and usable decryption depends on active session state.&lt;/p&gt;
&lt;p&gt;The platform methods reflect that design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;syncAutofillCredentialSnapshot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clearAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If session key is cleared on lock/logout, Autofill stops returning datasets.&lt;/p&gt;
&lt;p&gt;Flutter side bridge is intentionally thin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; syncCredentialSnapshot({
  required String payload,
  required String sessionKeyBase64,
}) async {
  final state = await availability();
  if (state != AutofillMvpAvailability.available) {
    return;
  }
  await _bridge.setSessionKey(sessionKeyBase64);
  await _bridge.syncCredentialSnapshot(payload);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That sequence matters. Native side receives encrypted snapshot plus active session key only when feature is available and vault is in usable state.&lt;/p&gt;
&lt;p&gt;This is a strong boundary design because it minimizes plaintext residency and ties Autofill usefulness to vault unlock state rather than treating Autofill as a separate always-on data channel.&lt;/p&gt;
&lt;h3 id=&quot;locking-clipboard-and-leakage-controls&quot;&gt;Locking, Clipboard, and Leakage Controls&lt;/h3&gt;
&lt;p&gt;Security work is often ruined by small post-decrypt leaks rather than broken cryptography.&lt;/p&gt;
&lt;p&gt;OneRule includes several controls in this category:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;screenshot prevention and screen-capture leakage protection on mobile&lt;/li&gt;
&lt;li&gt;auto-lock timeout based on app lifecycle transitions&lt;/li&gt;
&lt;li&gt;clipboard auto-clear after configurable delay&lt;/li&gt;
&lt;li&gt;optional application of clipboard policy to usernames as well&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lifecycle lock path is especially relevant in Flutter apps, where it is easy to focus on route state and forget process state. OneRule tracks pause/resume transitions and clears session state when idle time exceeds configured threshold.&lt;/p&gt;
&lt;p&gt;The relevant branch in lifecycle observer is simple and effective:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;if (diff.inSeconds &amp;gt; timeoutSeconds) {
  storage.clearSessionKey();
  unawaited(PlatformCredentialProvider().onVaultLocked());
  unawaited(context.read&amp;lt;PasswordProvider&amp;gt;().lockForSession());
  Navigator.of(context).pushAndRemoveUntil(
    MaterialPageRoute(builder: (_) =&amp;gt; const LoginScreen()),
    (_) =&amp;gt; false,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is exactly kind of code I like seeing in security-sensitive mobile apps: short, direct, and tied to explicit state invalidation.&lt;/p&gt;
&lt;p&gt;That is not glamorous, but it is real security work.&lt;/p&gt;
&lt;h3 id=&quot;vault-health-analysis&quot;&gt;Vault Health Analysis&lt;/h3&gt;
&lt;p&gt;OneRule also includes a local &quot;Vault Health&quot; analyzer. This is not cryptography, but it is useful security-adjacent logic.&lt;/p&gt;
&lt;p&gt;The current report model scores the vault from 0 to 100 based on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;weak passwords&lt;/li&gt;
&lt;li&gt;exact-match duplicate passwords&lt;/li&gt;
&lt;li&gt;stale credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Duplicate detection uses local hash-based comparison logic rather than remote checks, which keeps analysis offline and aligned with rest of system design.&lt;/p&gt;
&lt;p&gt;This is a good example of how offline-first does not need to mean feature-light. You can still deliver useful security insights locally if the data model and processing path are designed for it.&lt;/p&gt;
&lt;h3 id=&quot;why-flutter-works-here&quot;&gt;Why Flutter Works Here&lt;/h3&gt;
&lt;p&gt;Security software is often assumed to require fully native implementation, but OneRule is a good example of when Flutter is enough for most of stack and native code is reserved for true platform edges.&lt;/p&gt;
&lt;p&gt;Flutter handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;application UI&lt;/li&gt;
&lt;li&gt;state management&lt;/li&gt;
&lt;li&gt;settings and backup flows&lt;/li&gt;
&lt;li&gt;vault rendering and search&lt;/li&gt;
&lt;li&gt;generator and health screens&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Native Android handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Autofill service&lt;/li&gt;
&lt;li&gt;platform-specific storage and bridge responsibilities&lt;/li&gt;
&lt;li&gt;native crash logging hooks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a practical split.&lt;/p&gt;
&lt;p&gt;Flutter gives fast iteration and a unified codebase. Native code stays focused on surfaces where Android APIs are non-negotiable. For a solo-built security product, that balance can make a big difference in maintainability.&lt;/p&gt;
&lt;h3 id=&quot;threat-model-boundaries&quot;&gt;Threat Model Boundaries&lt;/h3&gt;
&lt;p&gt;Good security writeups should include limits, not only protections.&lt;/p&gt;
&lt;p&gt;OneRule is designed to defend against:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;offline access to copied app data without the derived key&lt;/li&gt;
&lt;li&gt;theft or exfiltration of encrypted database files&lt;/li&gt;
&lt;li&gt;tampering of AES-GCM protected vault fields and backups&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is not designed to fully defend against:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rooted or fully compromised operating systems&lt;/li&gt;
&lt;li&gt;malware with runtime access while vault is unlocked&lt;/li&gt;
&lt;li&gt;PIN disclosure by user coercion, phishing, or keylogging&lt;/li&gt;
&lt;li&gt;clipboard interception before auto-clear executes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a reasonable and honest scope. Offline encryption can do a lot. It cannot fix a hostile runtime after trust boundary is already lost.&lt;/p&gt;
&lt;h3 id=&quot;why-this-project-is-interesting&quot;&gt;Why This Project Is Interesting&lt;/h3&gt;
&lt;p&gt;A lot of apps claim privacy because they do not have obvious ads or trackers. OneRule is more interesting than that because privacy is expressed in structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no backend dependency for core vault flows&lt;/li&gt;
&lt;li&gt;explicit key derivation&lt;/li&gt;
&lt;li&gt;versioned encrypted payloads&lt;/li&gt;
&lt;li&gt;layered storage protection&lt;/li&gt;
&lt;li&gt;local-only backup generation&lt;/li&gt;
&lt;li&gt;native Autofill integration without persistent plaintext snapshot model&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is what I like about the project technically. The product promise and implementation choices mostly line up.&lt;/p&gt;
&lt;p&gt;That is rarer than it should be.&lt;/p&gt;
&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;
&lt;p&gt;If you want to study a Flutter security app that takes local-first architecture seriously, OneRule is worth reading through.&lt;/p&gt;
&lt;p&gt;It is not trying to out-feature every cloud password manager. It is solving a narrower problem: how to build a usable password vault where local storage, transparent cryptographic boundaries, and offline operation are first-class constraints rather than marketing copy.&lt;/p&gt;
&lt;p&gt;Project links:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/seralifatih/OneRule&quot;&gt;https://github.com/seralifatih/OneRule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.fidevelopment.onerule&quot;&gt;https://play.google.com/store/apps/details?id=com.fidevelopment.onerule&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you build security software in Flutter, this kind of project is a useful reminder that &quot;mobile-first&quot; and &quot;security-conscious&quot; do not have to be opposites. With careful boundaries, versioned crypto payloads, and disciplined state handling, a local-first password manager can stay practical without outsourcing trust to infrastructure.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14676/building-onerule-technical-deep-dive-into-offline-password-manager-with-flutter-sqlcipher</guid>
<pubDate>Mon, 13 Apr 2026 12:48:19 +0000</pubDate>
</item>
<item>
<title>Flutter vs React Native AI Apps: My 2026 Take</title>
<link>https://coderlegion.com/14039/flutter-vs-react-native-ai-apps-my-2026-take</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.buildzn.com/blog/flutter-vs-react-native-ai-apps-my-2026-take&quot;&gt;BuildZn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Everyone talks about how AI will change mobile, but nobody explains which tech stack actually gets you there without burning through your seed round. You’re Googling &lt;strong&gt;Flutter vs React Native AI apps&lt;/strong&gt; right now because you need clarity, not marketing fluff. I spent a solid week building a proof-of-concept for an on-device AI feature last month, wrestling with both frameworks. Here's what actually works, and where each framework shines (or trips).&lt;/p&gt;
&lt;h3 id=&quot;why-ai-powered-mobile-apps-aren-t-just-hype-anymore&quot;&gt;Why AI-Powered Mobile Apps Aren't Just Hype Anymore&lt;/h3&gt;
&lt;p&gt;Look, in 2026, if your mobile app isn't at least thinking about AI, it's already behind. It's not about chatbots anymore; it’s about &lt;strong&gt;AI-powered mobile app development&lt;/strong&gt; driving real user value and efficiency. Think personalized content feeds, real-time object recognition, intelligent automation, or even predictive analytics directly on the user's phone.&lt;/p&gt;
&lt;p&gt;We’re past the experimental phase. Users expect smart features. Founders, you need to decide if you’re building a product that just &lt;em&gt;uses&lt;/em&gt; AI via an API call to OpenAI, or if you're embedding complex machine learning models directly into the app for speed, offline capability, and better data privacy. This choice dictates a lot about your framework, cost, and overall strategy.&lt;/p&gt;
&lt;h3 id=&quot;flutter-vs-react-native-ai-apps-the-real-deal-for-your-wallet-and-timeline&quot;&gt;Flutter vs React Native AI Apps: The Real Deal for Your Wallet and Timeline&lt;/h3&gt;
&lt;p&gt;Alright, let's get into the brass tacks. You care about cost, time to market, and how well this thing will perform when it's handling real AI tasks. My experience across over 20 production apps – including FarahGPT with its 5,100+ users and a complex gold trading system – gives me a pretty clear view.&lt;/p&gt;
&lt;p&gt;Here’s the breakdown:&lt;/p&gt;
&lt;h4 id=&quot;flutter-for-ai-powered-apps&quot;&gt;Flutter for AI-Powered Apps&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Performance:&lt;/strong&gt; This is where Flutter often wins, hands down, for serious on-device AI. Because it compiles directly to native code, you get near-native performance. When you’re running a TensorFlow Lite model for real-time image processing or audio analysis, those milliseconds matter. The UI stays smooth even when the CPU is churning through ML inferences.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Development Speed &amp;amp; Cost:&lt;/strong&gt; My team ships fast with Flutter. A single codebase for iOS and Android means half the development effort compared to building two separate native apps. For &lt;strong&gt;mobile app framework AI 2026&lt;/strong&gt; discussions, this translates directly into &lt;strong&gt;lower development costs and faster timelines&lt;/strong&gt;. One team, one codebase, one set of AI integrations. Simpler to maintain too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI Integration:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On-Device ML:&lt;/strong&gt; Flutter's integration with TensorFlow Lite is solid. You drop your &lt;code&gt;.tflite&lt;/code&gt; model, use the &lt;code&gt;tflite_flutter&lt;/code&gt; package, and you're good. For platform-specific features like Apple's Core ML or Google's ML Kit, Flutter's FFI (Foreign Function Interface) or platform channels let you tap into native capabilities directly with minimal boilerplate. Honestly, this direct access is a huge advantage for complex, performance-critical AI tasks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud-Based AI:&lt;/strong&gt; For calling APIs like OpenAI, Google Gemini, or custom ML inference services, both frameworks are equally capable. It’s just standard HTTP requests, which Flutter handles efficiently.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;react-native-for-ai-powered-apps&quot;&gt;React Native for AI-Powered Apps&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ecosystem &amp;amp; Flexibility:&lt;/strong&gt; React Native rides on the massive JavaScript ecosystem. If you’re already heavy into web development and have JS talent, the learning curve is shallower. There are plenty of existing JS libraries, and some bridge to native AI SDKs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance:&lt;/strong&gt; This is where it gets tricky. For basic API calls to cloud AI, React Native is fine. But for intensive &lt;strong&gt;on-device machine learning apps&lt;/strong&gt;, the JavaScript bridge can introduce overhead. When you need to process large datasets or run complex models in real-time, that bridge can become a bottleneck. You often end up writing native modules (Java/Kotlin for Android, Swift/Objective-C for iOS) for performance-critical parts, which defeats the &quot;single codebase&quot; promise and drives up costs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Development Speed &amp;amp; Cost:&lt;/strong&gt; Initial setup can be quick if you're familiar with React. However, once you hit native feature requirements or performance bottlenecks for AI, development slows down. You need developers proficient in React Native &lt;em&gt;and&lt;/em&gt; native iOS/Android, effectively increasing your team size or skill requirements. This can lead to higher long-term maintenance costs and longer development cycles for complex &lt;strong&gt;AI-powered mobile app development&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI Integration:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On-Device ML:&lt;/strong&gt; Requires finding or building native modules that expose TensorFlow Lite, Core ML, or ML Kit to JavaScript. While packages exist (e.g., &lt;code&gt;react-native-tflite-camera&lt;/code&gt;), they often lag behind native SDK updates or have limitations. When you need something cutting-edge, you might be building a lot of custom native code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud-Based AI:&lt;/strong&gt; Just like Flutter, React Native handles API calls to cloud AI services perfectly well using standard libraries like Axios or Fetch.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;so-who-wins-for-ai-&quot;&gt;So, who wins for AI?&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;My take: If your AI strategy leans heavily on custom, on-device machine learning models for real-time processing or offline capabilities, Flutter is the stronger choice.&lt;/strong&gt; The performance advantage and simpler native integration pathways are a game-changer. &lt;strong&gt;If your AI is primarily cloud-based (API calls) and you have an existing JS team, React Native can work, but be prepared for potential hurdles if you later need serious on-device ML.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For founders, this translates to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flutter = More predictable costs, faster iteration for complex AI, better performance ceilings.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React Native = Potentially faster initial setup if JS-heavy, but higher risk of costly native refactors for advanced AI needs.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-ai-integration-actually-looks-not-just-theory-&quot;&gt;How AI Integration Actually Looks (Not Just Theory)&lt;/h3&gt;
&lt;p&gt;When we built FarahGPT, the core logic was entirely backend. The mobile app (Flutter, naturally) just sent user input to our Node.js API, which then talked to OpenAI. Simple network calls. That’s a common, effective strategy for many &lt;strong&gt;best framework for AI startups&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;But what if you're building something else?&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cloud-Based AI (API Calls):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is the easiest path. Your mobile app sends data (text, image, audio) to a backend service (your Node.js API, or a direct call to Google Gemini/AWS Rekognition).&lt;/li&gt;
&lt;li&gt;The backend processes it, uses an AI model, and sends the result back to the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Both Flutter and React Native handle this beautifully.&lt;/strong&gt; It's just HTTP requests. The choice here comes down to your general mobile app strategy, not AI specific issues.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Example: Flutter calling an AI API
Future&amp;lt;String&amp;gt; getAIResponse(String prompt) async {
  final response = await http.post(
    Uri.parse('https://api.farahgpt.com/generate'),
    headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_API_KEY'},
    body: jsonEncode({'text': prompt}),
  );

  if (response.statusCode == 200) {
    return jsonDecode(response.body)['response'];
  } else {
    throw Exception('Failed to get AI response: ${response.body}');
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code is straightforward. React Native would look similar with &lt;code&gt;fetch&lt;/code&gt; or &lt;code&gt;axios&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;On-Device AI (Local Models):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is for scenarios where you need:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Real-time processing:&lt;/strong&gt; Image filters, speech-to-text, object detection that can't wait for network latency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offline functionality:&lt;/strong&gt; AI features work without an internet connection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data privacy:&lt;/strong&gt; Sensitive data never leaves the device.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This is where Flutter truly shines for &lt;strong&gt;Flutter for machine learning apps&lt;/strong&gt;. With TensorFlow Lite, you embed the model directly. For example, in my gold trading system, some initial data validation and pattern recognition happened on-device before hitting the main backend.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flutter Implementation:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;tflite_flutter&lt;/code&gt; to your &lt;code&gt;pubspec.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Place your &lt;code&gt;.tflite&lt;/code&gt; model in your assets.&lt;/li&gt;
&lt;li&gt;Load the model and run inferences.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;React Native Implementation:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Requires &lt;code&gt;react-native-tflite-camera&lt;/code&gt; or similar, which might involve linking native modules.&lt;/li&gt;
&lt;li&gt;Often involves more complex setup and potential versioning issues with native SDKs.&lt;/li&gt;
&lt;li&gt;Performance-sensitive parts might still push you to write custom native modules.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Here's the thing — don't overcomplicate it if you don't have to.&lt;/strong&gt; Most startups can start with cloud-based AI. Only go on-device if you absolutely need the speed, offline, or privacy benefits.&lt;/p&gt;
&lt;h3 id=&quot;what-most-founders-get-wrong-about-ai-apps&quot;&gt;What Most Founders Get Wrong About AI Apps&lt;/h3&gt;
&lt;p&gt;I've seen founders waste a lot of cash and time because of some common misunderstandings. Don't be that founder.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mistake 1: Assuming &quot;AI&quot; means everything happens on the phone.&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Reality:&lt;/em&gt; For 90% of use cases, your app just sends data to a powerful AI model running in the cloud. Think about how ChatGPT works – your phone isn't running a large language model. It's hitting an API. Focus on a robust backend API strategy first.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mistake 2: Underestimating the data required.&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Reality:&lt;/em&gt; Your AI model is only as good as the data it's trained on. If you're building a custom AI feature, collecting and curating quality data is often 10x harder than picking a framework.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mistake 3: Ignoring long-term maintenance costs for native modules.&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Reality:&lt;/em&gt; If you pick React Native but end up writing a lot of custom native code for AI, you now have three codebases to maintain (JS, iOS Native, Android Native). Updates, bug fixes, and feature enhancements become much more expensive. &lt;strong&gt;Flutter generally avoids this trap by handling native interactions more gracefully.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mistake 4: Not factoring in security and privacy from day one.&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Reality:&lt;/em&gt; If your AI processes sensitive user data, you need to think about encryption, data anonymization, and compliance (GDPR, HIPAA, etc.). This applies to both on-device and cloud AI. Your framework choice won't fix poor security architecture.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;optimizing-your-ai-app-beyond-the-framework&quot;&gt;Optimizing Your AI App: Beyond the Framework&lt;/h3&gt;
&lt;p&gt;Picking between &lt;strong&gt;Flutter vs React Native AI apps&lt;/strong&gt; is just one piece of the puzzle. To build a truly performant, scalable, and secure AI app, you need to consider the whole stack.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Backend Choice:&lt;/strong&gt; For AI APIs, I often lean on Node.js. It's fast, scalable, and great for handling concurrent requests to external AI services or your own custom models. My experience building the backend for FarahGPT and the gold trading system confirms this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud AI Services:&lt;/strong&gt; Don't reinvent the wheel. Google Cloud ML Kit, AWS Rekognition/SageMaker, Azure AI services – these provide pre-trained models and managed infrastructure that can save you years of development. Use them, especially in the early stages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Security &amp;amp; Compliance:&lt;/strong&gt; Crucial for any app, but especially for AI. Encrypt data in transit and at rest. If processing sensitive info, ensure your backend infrastructure meets compliance standards.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scalability:&lt;/strong&gt; If your AI app takes off (and hopefully it does!), your backend needs to scale. Plan for serverless functions (AWS Lambda, Google Cloud Functions) or containerized services (Kubernetes) to handle increased AI inference requests.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;faqs-2&quot;&gt;FAQs&lt;/h3&gt;
&lt;h4 id=&quot;q1-which-framework-is-cheaper-for-an-ai-startup-&quot;&gt;Q1: Which framework is cheaper for an AI startup?&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; For basic cloud AI, costs are similar. For complex on-device AI, &lt;strong&gt;Flutter often offers lower overall TCO&lt;/strong&gt; due to better performance, fewer native development needs, and thus, fewer specialized developers required.&lt;/p&gt;
&lt;h4 id=&quot;q2-can-i-use-existing-ai-models-with-flutter-react-native-&quot;&gt;Q2: Can I use existing AI models with Flutter/React Native?&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, both can consume cloud AI APIs (like OpenAI, Google Gemini). For on-device, Flutter integrates well with TensorFlow Lite directly. React Native typically requires community packages or custom native modules for local ML.&lt;/p&gt;
&lt;h4 id=&quot;q3-what-if-i-need-super-fast-real-time-ai-processing-&quot;&gt;Q3: What if I need super fast, real-time AI processing?&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; If low latency and high throughput are critical, &lt;strong&gt;Flutter is generally preferred for its near-native performance&lt;/strong&gt; when running on-device models. This reduces reliance on network speeds and minimizes UI jank.&lt;/p&gt;
&lt;p&gt;Look, by 2026, every app will have some AI baked in. The choice of &lt;strong&gt;mobile app framework AI 2026&lt;/strong&gt; is critical. My strong opinion? For a serious &lt;strong&gt;AI-powered mobile app development&lt;/strong&gt; project, especially if you foresee needing any significant on-device machine learning or truly custom experiences, &lt;strong&gt;Flutter is the clear winner for its performance, developer efficiency, and future-proofing.&lt;/strong&gt; React Native can get you there for simpler, API-driven AI, but be ready to compromise on performance or budget if you push its limits. Don't let framework hype distract you from building actual value.&lt;/p&gt;
&lt;p&gt;Want to talk through your specific AI app idea and figure out the best stack for your budget and timeline? I've been there, done that. Let's schedule a quick call – my calendar link is [Your Calendar Link Here].&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14039/flutter-vs-react-native-ai-apps-my-2026-take</guid>
<pubDate>Mon, 06 Apr 2026 09:04: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 to Build Responsive Flutter Apps for Phones, Foldables, Tablets &amp; Web (2026)</title>
<link>https://coderlegion.com/13331/how-to-build-responsive-flutter-apps-for-phones-foldables-tablets-web-2026</link>
<description>&lt;p&gt;Hey everyone, Samuel here! In 2026, your Flutter app needs to look perfect on a tiny phone, a folding tablet, a desktop window, and even on the web — all from one codebase.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Nothing kills user experience faster than a layout that breaks on different screen sizes.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Today, we’re doing a complete how-to: building fully responsive UIs using MediaQuery, LayoutBuilder, breakpoints, and the best scaling tricks in 2026. By the end of this article, you’ll have all you need to build professional apps that automatically adapt everywhere. Let’s jump in!&lt;/p&gt;
&lt;h5 id=&quot;why-responsive-matters-core-concepts&quot;&gt;Why Responsive Matters + Core Concepts&lt;/h5&gt;
&lt;p&gt;First, a quick difference: &lt;strong&gt;Responsive design &lt;/strong&gt;means the UI scales and reflows. &lt;strong&gt;Adaptive design &lt;/strong&gt;means it completely changes the structure (e.g., showing a side menu on a tablet). In 2026, users expect buttery smooth adaptation — no overflow, no tiny text, no broken layouts. The two weapons we’ll use most:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MediaQuery → get screen size, orientation, padding&lt;/li&gt;
&lt;li&gt;LayoutBuilder → get exact constraints of the parent widget&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are built-in, zero dependencies, and still the foundation.&lt;/p&gt;
&lt;h5 id=&quot;mediaquery-amp-basic-responsiveness&quot;&gt;&lt;strong&gt;MediaQuery &amp;amp; Basic Responsiveness&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;Let’s start simple. In your home screen:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;double screenWidth = MediaQuery.of(context).size.width;
double screenHeight = MediaQuery.of(context).size.height;
bool isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now build a profile card:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Container(
  width: screenWidth * 0.9,           // 90% of screen
  height: screenHeight * 0.25,
  padding: const EdgeInsets.all(20),
  decoration: BoxDecoration(... glassmorphism ...),
  child: Column(...)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;See how it automatically scales?&lt;/p&gt;
&lt;p&gt;Next, orientation handling:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;if (isLandscape) {
  return Row(children: [sidebar, mainContent]);
} else {
  return Column(children: [header, mainContent]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That already makes it way better on tablets and desktops.&lt;/p&gt;
&lt;h5 id=&quot;advanced-layoutbuilder-breakpoints&quot;&gt;&lt;strong&gt;Advanced — LayoutBuilder + Breakpoints&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;Now the real power tool: LayoutBuilder.&lt;/p&gt;
&lt;p&gt;Wrap your Scaffold body:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth &amp;gt;= 1024) return DesktopLayout();
    if (constraints.maxWidth &amp;gt;= 600) return TabletLayout();
    return MobileLayout();
  },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We have three separate widgets: MobileLayout (stacked), TabletLayout (two-column), and DesktopLayout (sidebar + content).&lt;/p&gt;
&lt;p&gt;The layout instantly switches as you resize the window, no refresh needed.&lt;/p&gt;
&lt;h5 id=&quot;scaling-magic-with-flutter-screenutil-2026-best-practice-&quot;&gt;&lt;strong&gt;Scaling Magic with Flutter ScreenUtil (2026 Best Practice)&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;For pixel-perfect scaling across all devices, we can still use &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/flutter_screenutil&quot;&gt;flutter_screenutil&lt;/a&gt; in 2026.&lt;/p&gt;
&lt;p&gt;Add to pubspec:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  flutter_screenutil: ^5.9.3   # latest stable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in the main.dart:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ScreenUtilInit(
  designSize: const Size(375, 812),   // iPhone 12/13 size
  minTextAdapt: true,
  splitScreenMode: true,
  builder: (context, child) =&amp;gt; MaterialApp(...)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Text(’Hello’, style: TextStyle(fontSize: 16.sp)),
Container(width: 200.w, height: 50.h)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It scales fonts, padding, everything automatically. Combine this with LayoutBuilder, and you have production-ready responsive UIs.&lt;/p&gt;
&lt;p&gt;Quick pause before we finish, I want to invite you to my free newsletter personally. Every week, I send the exact code snippets, early access to full project repos, and the latest 2026 tips that I don’t always cover in videos. It’s completely free, zero spam, and it’s the fastest way to level up. Just click this link: &lt;a rel=&quot;nofollow&quot; href=&quot;https://techwithsam.dev/newsletter&quot;&gt;https://techwithsam.dev/newsletter&lt;/a&gt; — I’d love to see you there!&lt;/p&gt;
&lt;h5 id=&quot;best-practices-amp-safearea&quot;&gt;&lt;strong&gt;Best Practices &amp;amp; SafeArea&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;Final pro tips for 2026:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Always wrap top-level content in SafeArea&lt;/li&gt;
&lt;li&gt;Use MediaQuery.padding for notches and dynamic islands&lt;/li&gt;
&lt;li&gt;Limit max width on large screens: ConstrainedBox(maxWidth: 1200)&lt;/li&gt;
&lt;li&gt;Test in all orientations + web + different window sizes&lt;/li&gt;
&lt;li&gt;Use const everywhere&lt;/li&gt;
&lt;li&gt;Profile with DevTools — responsive UIs should stay under 8ms per frame&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s it! You now have everything to build beautiful, responsive UIs that work everywhere. If you have any questions, make sure to drop them in the comment section.&lt;/p&gt;
&lt;p&gt;Don’t forget to join my 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;/em&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtube.com/@techwithsam&quot;&gt;YouTube&lt;/a&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/13331/how-to-build-responsive-flutter-apps-for-phones-foldables-tablets-web-2026</guid>
<pubDate>Sun, 22 Mar 2026 20:07:58 +0000</pubDate>
</item>
<item>
<title>Stitch + Antigravity + Flutter: Build Apps with AI Agents in 2026</title>
<link>https://coderlegion.com/13330/stitch-antigravity-flutter-build-apps-with-ai-agents-in-2026</link>
<description>&lt;p&gt;In 2026, the way we build Flutter apps has completely changed. You no longer start by opening VS Code and typing widgets line by line.&lt;/p&gt;
&lt;p&gt;Instead, you describe your app in plain English, an AI agent designs the entire UI, another agent writes and wires up the code, and you only guide the process.&lt;/p&gt;
&lt;p&gt;That’s exactly what Google’s &lt;strong&gt;Stitch&lt;/strong&gt; + &lt;strong&gt;Antigravity&lt;/strong&gt; + &lt;strong&gt;Flutter&lt;/strong&gt; combo delivers.&lt;/p&gt;
&lt;p&gt;Today I’m going to show you the complete end-to-end workflow: from a simple prompt to a fully functional Flutter app in under a few minutes. This is the new skill every Flutter developer needs in 2026.&lt;/p&gt;
&lt;h4 id=&quot;why-this-workflow-matters-right-now&quot;&gt;&lt;strong&gt;Why This Workflow Matters Right Now&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://stitch.withgoogle.com/&quot;&gt;&lt;strong&gt;Stitch&lt;/strong&gt;&lt;/a&gt; is Google’s AI design agent. You give it a text prompt or even a rough sketch, and it instantly generates production-ready UI designs and structured metadata.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://antigravity.google/&quot;&gt;&lt;strong&gt;Antigravity&lt;/strong&gt;&lt;/a&gt; is Google’s agentic IDE — a next-gen VS Code fork powered by Gemini. It doesn’t just suggest code; it plans, runs terminal commands, installs packages, writes tests, and iterates until the app works.&lt;/p&gt;
&lt;p&gt;When you connect Stitch output to Antigravity via the new &lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; connectors, the AI agent converts the design into real Flutter + Dart code.&lt;/p&gt;
&lt;p&gt;This is what the community is calling “vibe coding” or “agentic development.” The developer is still in control — you review, tweak prompts, and make final decisions. But the heavy lifting is handled by agents.&lt;/p&gt;
&lt;p&gt;Perfect for rapid prototyping, MVPs, or even production features.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites &amp;amp; Setup&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Before we build:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Flutter 3.27+ (stable)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Google account with access to Antigravity (&lt;a rel=&quot;nofollow&quot; href=&quot;http://antigravity.google&quot;&gt;antigravity.google&lt;/a&gt;)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Stitch access at &lt;a rel=&quot;nofollow&quot; href=&quot;http://stitch.withgoogle.com&quot;&gt;stitch.withgoogle.com&lt;/a&gt; (Google Labs)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Step 1: Install Antigravity (it’s a separate IDE). Download from &lt;a rel=&quot;nofollow&quot; href=&quot;http://antigravity.google&quot;&gt;antigravity.google&lt;/a&gt; and open it.&lt;/p&gt;
&lt;p&gt;Step 2: Enable MCP connectors in Antigravity settings (this links Stitch directly).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/24hv7dhcewy708ast3ys.png&quot; alt=&quot;Antigravity MCP&quot;&gt;&lt;/p&gt;
&lt;p&gt;Step 3: Create a new Flutter project in Antigravity or create an empty folder and open it on Antigravity; it will handle every other setup.&lt;/p&gt;
&lt;p&gt;Live Build Demo — Let’s Build a Real App. We’re going to build a simple ‘Daily Habit Tracker’ app using only prompts.&lt;/p&gt;
&lt;p&gt;First, open Stitch:&lt;/p&gt;
&lt;p&gt;Prompt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;Create a clean, modern mobile habit tracker application UI designed to help users build consistent daily routines and maintain positive habits. The design should feel minimal, motivating, and calming, encouraging users to stay consistent with their goals. Use a modern, accessible color system with a primary accent color and soft neutral backgrounds. Support both light mode and dark mode. Use rounded cards, subtle shadows, smooth spacing, and simple iconography to create a polished mobile experience. The application should include bottom navigation with the following sections:

Home / Today Screen

*   Display a daily streak counter at the top with a visually engaging streak indicator (flame or progress ring).
    
*   Show today’s habits list in card format.
    
*   Each habit should include:
    
    *   A checkbox or toggle to mark completion.
        
    *   Habit name.
        
    *   Optional small progress indicator.
        
*   Include a progress summary showing how many habits are completed for the day.
    
*   Add a motivational quote section at the bottom to inspire users.
    

Habits Screen

*   Display a list of all created habits.
    
*   Each habit card should show:
    
    *   Habit name
        
    *   Current streak
        
    *   Completion progress
        
*   Provide an “Add Habit” floating action button.
    
*   Allow editing or deleting habits.
    

Progress / Statistics Screen

*   Display weekly and monthly habit completion charts.
    
*   Show streak history and completion percentages.
    
*   Include visual elements like line charts or progress rings.
    

Profile / Settings Screen

*   Display a circular user avatar.
    
*   Show user statistics such as:
    
    *   Total habits created
        
    *   Longest streak
        
    *   Total days completed
        
*   Include settings such as:
    
    *   Dark mode toggle
        
    *   Notification reminders
        
    *   Habit reminder times
        

Design Style

*   Minimalist and modern mobile UI
    
*   Rounded cards and soft shadows
    
*   Smooth spacing and readable typography
    
*   Friendly icons and subtle animations
    
*   Clean bottom navigation bar
    
*   Consistent spacing and hierarchy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Stitch instantly generates the full UI design + metadata. Now select the screens generated by Stitch and click on Export — then select MCP&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n2e68uo2g0lyorssg9lw.png&quot; alt=&quot;Stitch Export&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now copy the Prompt and move to Antigravity:&lt;/p&gt;
&lt;p&gt;Open the Agent Tab and paste the prompt from Stitch&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/34ykjt0ixok2toum0jl0.png&quot; alt=&quot;Stitch Prompt&quot;&gt;&lt;/p&gt;
&lt;p&gt;After Antigravity is done downloading all the screens, then you can give it an instruction of what to build e.g&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;Use the UI layout generated from Stitch and build a Flutter application using Dart. 
The project should follow Clean Architecture and separate presentation, domain, and data layers. 
Use the Riverpod for state management. Ensure UI components are separated from business logic and follow a clear architecture project structure.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watch the magic afterward. By the way, the AI Agent will ask for permission to run some commands; make sure you go through and approve accordingly.&lt;/p&gt;
&lt;p&gt;After 10–12 minutes, run flutter run and the fully functional app appears on your emulator.&lt;/p&gt;
&lt;h4 id=&quot;best-practices-amp-pro-tips&quot;&gt;&lt;strong&gt;Best Practices &amp;amp; Pro Tips&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Key lessons from using this daily:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Always start with a clear, detailed prompt in Stitch (include platform, theme, accessibility).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In Antigravity, use the ‘Manager Agent’ mode and give it one task at a time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Review every generated file — AI is fast but not perfect yet (especially complex state logic).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Combine with your existing skills: Use Riverpod, GoRouter, and Impeller optimizations from my earlier videos.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Limitations: Native plugins and heavy custom logic still need human oversight.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pro move: Save your best prompts as reusable skills in Antigravity for future projects.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This workflow is gaining traction in the Flutter community right now because it lets you go from idea to a working app faster than ever.&lt;/p&gt;
&lt;p&gt;Quick pause before we finish, if you’re serious about mastering AI tools like this, join my free Flutter + AI newsletter. Every week, I share the exact prompts, workflows, and early access to new agent skills that I test before they hit YouTube. Completely free, no spam — just the most relevant stuff for Flutter developers. &lt;a rel=&quot;nofollow&quot; href=&quot;https://techwithsam.dev/newsletter&quot;&gt;https://techwithsam.dev/newsletter&lt;/a&gt; — I’d love to see you there!&lt;/p&gt;
&lt;p&gt;This is the new reality of Flutter development in 2026 — Stitch for design, Antigravity for agentic building, and you as the conductor.&lt;/p&gt;
&lt;p&gt;Don’t forget to join my 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;/em&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtube.com/@techwithsam&quot;&gt;YouTube&lt;/a&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/13330/stitch-antigravity-flutter-build-apps-with-ai-agents-in-2026</guid>
<pubDate>Sun, 22 Mar 2026 19:55:52 +0000</pubDate>
</item>
<item>
<title>Refined Your Flutter Recipe App using Gemini AI</title>
<link>https://coderlegion.com/12910/refined-your-flutter-recipe-app-using-gemini-ai</link>
<description>&lt;p&gt;Hola..Back to article writing and I want to tell a story about my journey as Boarding House Citizen. Living in a boarding house alone, sometimes leaving leftover food can feel kind of unreal in a good way. Back at home, leftovers usually came with rules, reminders, or a bit of guilt, especially after big celebrations like &lt;em&gt;Eid Al-Fitr&lt;/em&gt; or &lt;em&gt;Christmas&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Here, it’s not like that at all. The fridge is shared, containers are labeled, and everyone knows that food can be shared if it’s meant to be. When I cook too much or can’t finish my meal, I just pack it up instead of forcing myself to eat more. Someone else might enjoy it later after a long day at work or a late night of studying. It just makes sense.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Food leftovers should not be wasted&lt;/strong&gt;, even though many people feel unsure what to cook with them. Leftover rice, vegetables, or bread can be turned into simple meals like fried rice, soup, or toast. Checking the fridge before cooking helps reduce waste. Using leftovers saves money, cut daily meal cost, and is good for the environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Now imagine you could build a real-life app that calculates and combines all your leftover food into one new recipe and shows the budget cost like you’ll never think of&lt;/strong&gt;? Now we’ll make it using flutter.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.stackademic.com/refined-your-flutter-recipe-app-using-gemini-ai-46a11884e473&quot;&gt;Note: due to character limitation, see full article on my Medium&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/12910/refined-your-flutter-recipe-app-using-gemini-ai</guid>
<pubDate>Mon, 16 Mar 2026 03:43:11 +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>How to Push Over-the-Air Flutter Updates with Shorebird — Complete 2026 Guide</title>
<link>https://coderlegion.com/12374/how-to-push-over-the-air-flutter-updates-with-shorebird-complete-2026-guide</link>
<description>&lt;p&gt;One of the biggest frustrations for mobile devs is still waiting days or weeks for App Store or Play Store approvals to ship a small bug fix, UI tweak, or critical security patch. What if you could push those changes instantly, over-the-air, directly to your users' devices? No resubmission, no waiting, no lost users. That's exactly what @shorebird  Code Push delivers: true over-the-air (OTA) updates for Dart code in Flutter apps.&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/12374/how-to-push-over-the-air-flutter-updates-with-shorebird-complete-2026-guide</guid>
<pubDate>Wed, 04 Mar 2026 20:40:21 +0000</pubDate>
</item>
</channel>
</rss>