<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Mobile Development - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Mobile Development - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 29 May 2026 23:03:37 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/mobile-development/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Learn Command Line Interface (CLI) Development with Dart: From Zero to a Fully Published Developer Tool ]]>
                </title>
                <description>
                    <![CDATA[ Most developers spend a significant portion of their day in the terminal. They run flutter build, push with git, manage packages with dart pub, and orchestrate pipelines from the command line. Every o ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-command-line-interface-cli-development-with-dart-from-zero-to-a-fully-published-developer-tool/</link>
                <guid isPermaLink="false">69fe3149f239332df4fdfd46</guid>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cli ]]>
                    </category>
                
                    <category>
                        <![CDATA[ command line ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 18:54:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a4c564c2-f5f3-4824-b4e7-d103b5fc488e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most developers spend a significant portion of their day in the terminal. They run <code>flutter build</code>, push with <code>git</code>, manage packages with <code>dart pub</code>, and orchestrate pipelines from the command line. Every one of those tools is a CLI, or command line interface: a program that lives in the terminal and responds to text commands.</p>
<p>Yet most developers have never built one.</p>
<p>That's a missed opportunity. CLI tools are one of the most practical things a developer can ship. They automate repetitive workflows, standardise processes across teams, and, when published, become tangible artifacts that the developer community can discover, install, and use.</p>
<p>In this handbook, you'll go from zero to building a fully distributed Dart CLI tool. We'll start with the fundamentals – how CLIs work, how Dart receives and processes terminal input, and the core syntax you need to know. Then we'll build three progressively complex CLIs, starting with the basics and finishing with a real-world API request runner. Finally, we will cover every distribution path available, from <code>pub.dev</code> to compiled binaries, Homebrew taps, Docker, and local team activation.</p>
<p>By the end of the guide, you'll understand both how to build a CLI tool in Dart as well as how to ship it so other developers can actually use it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-a-cli-and-why-should-you-build-one">What is a CLI and Why Should You Build One?</a></p>
</li>
<li><p><a href="#heading-cli-syntax-anatomy">CLI Syntax Anatomy</a></p>
</li>
<li><p><a href="#heading-how-dart-receives-terminal-input">How Dart Receives Terminal Input</a></p>
</li>
<li><p><a href="#heading-core-cli-concepts-in-dart">Core CLI Concepts in Dart</a></p>
<ul>
<li><p><a href="#heading-stdout-stderr-and-stdin">stdout, stderr, and stdin</a></p>
</li>
<li><p><a href="#heading-exit-codes">Exit Codes</a></p>
</li>
<li><p><a href="#heading-environment-variables">Environment Variables</a></p>
</li>
<li><p><a href="#heading-file-and-directory-operations">File and Directory Operations</a></p>
</li>
<li><p><a href="#heading-running-external-processes">Running External Processes</a></p>
</li>
<li><p><a href="#heading-platform-detection">Platform Detection</a></p>
</li>
<li><p><a href="#heading-async-in-cli">Async in CLI</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-setting-up-your-dart-cli-project">Setting Up Your Dart CLI Project</a></p>
</li>
<li><p><a href="#heading-cli-1-hello-cli-the-fundamentals">CLI 1 — Hello CLI: The Fundamentals</a></p>
</li>
<li><p><a href="#heading-cli-2-darttodo-a-terminal-task-manager">CLI 2 — dart_todo: A Terminal Task Manager</a></p>
<ul>
<li><p><a href="#heading-introducing-the-args-package">Introducing the args Package</a></p>
</li>
<li><p><a href="#heading-building-darttodo">Building dart_todo</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-cli-3-darthttp-a-lightweight-api-request-runner">CLI 3 — dart_http: A Lightweight API Request Runner</a></p>
<ul>
<li><a href="#heading-building-darthttp">Building dart_http</a></li>
</ul>
</li>
<li><p><a href="#heading-adding-color-and-polish-to-your-cli">Adding Color and Polish to Your CLI</a></p>
</li>
<li><p><a href="#heading-testing-your-cli-tool">Testing Your CLI Tool</a></p>
</li>
<li><p><a href="#heading-deploying-and-distributing-your-cli">Deploying and Distributing Your CLI</a></p>
<ul>
<li><p><a href="#heading-mode-1-pubdev-public-package-distribution">Mode 1: pub.dev — Public Package Distribution</a></p>
</li>
<li><p><a href="#heading-mode-2-local-path-activation">Mode 2: Local Path Activation</a></p>
</li>
<li><p><a href="#heading-mode-3-compiled-binary-via-github-releases">Mode 3: Compiled Binary via GitHub Releases</a></p>
</li>
<li><p><a href="#heading-mode-4-homebrew-tap">Mode 4: Homebrew Tap</a></p>
</li>
<li><p><a href="#heading-mode-5-docker">Mode 5: Docker</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-choosing-the-right-distribution-mode">Choosing the Right Distribution Mode</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ul>
<li><p>Dart SDK installed (<code>dart --version</code> should work in your terminal)</p>
</li>
<li><p>Basic familiarity with Dart syntax</p>
</li>
<li><p>Comfort with the terminal and running commands</p>
</li>
<li><p>A pub.dev account (for the publishing section)</p>
</li>
<li><p>A GitHub account (for the binary distribution section)</p>
</li>
</ul>
<h2 id="heading-what-is-a-cli-and-why-should-you-build-one">What is a CLI and Why Should You Build One?</h2>
<p>A CLI (or <strong>Command Line Interface</strong>) is a program you interact with entirely through text commands in a terminal, rather than through buttons and screens in a graphical interface.</p>
<p>Many of the tools you likely already rely on as a developer are CLI tools:</p>
<pre><code class="language-yaml">flutter build apk
git commit -m "fix: auth flow"
dart pub get
npm install
</code></pre>
<p>Flutter, Git, Dart, npm – all CLIs. You are already a CLI user every single day. This article is about becoming a CLI builder.</p>
<p>There are three strong reasons to build CLI tools as a developer:</p>
<ol>
<li><p><strong>Automating repetitive work:</strong> Anything you type more than twice a week is a candidate for automation. Generating boilerplate folder structures, running sequences of commands, scaffolding files, checking environments before a build a CLI turns a seven-step manual process into a single command.</p>
</li>
<li><p><strong>Standardising team workflows:</strong> Instead of a README that says "run these commands in this order," you ship one command that does all of it – consistently, every time, with no room for human error or a missed step.</p>
</li>
<li><p><strong>Building and publishing tooling.</strong> A published Dart CLI package is a tangible artifact. It shows up on pub.dev, gets installed and used by other developers, and communicates real engineering depth in a way that a portfolio or resume cannot.</p>
</li>
</ol>
<h2 id="heading-cli-syntax-anatomy">CLI Syntax Anatomy</h2>
<p>Before writing a single line of code, it helps to understand the structure of a CLI command. Every command follows a consistent pattern:</p>
<pre><code class="language-bash">tool [subcommand] [arguments] [options/flags]
</code></pre>
<p>Breaking down a real example:</p>
<pre><code class="language-bash">flutter build apk --release --obfuscate
│       │     │   │
tool    sub   arg  flags
</code></pre>
<ul>
<li><p><strong>Tool</strong> — the program itself (<code>flutter</code>, <code>dart</code>, <code>git</code>)</p>
</li>
<li><p><strong>Subcommand</strong> — the action being performed (<code>build</code>, <code>run</code>, <code>pub</code>)</p>
</li>
<li><p><strong>Arguments</strong> — what the action operates on (<code>apk</code>, <code>main.dart</code>, a filename)</p>
</li>
<li><p><strong>Flags and Options</strong> — modifiers that change behaviour</p>
</li>
</ul>
<p>There are two types of options:</p>
<pre><code class="language-plaintext">--release              # Boolean flag — either present or absent

--output=build/app     # Key-value option — name and a value
-v                     # Short flag — single hyphen, single character
</code></pre>
<p>This is the anatomy your CLIs will follow. Understanding it before writing any code means you will design your commands intentionally rather than stumbling into structure by accident.</p>
<h2 id="heading-how-dart-receives-terminal-input">How Dart Receives Terminal Input</h2>
<p>In Dart, everything the user types after your tool name is passed into your program through the <code>main</code> function:</p>
<pre><code class="language-dart">void main(List&lt;String&gt; args) {
  print(args);
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/mytool.dart hello world --name=Seyi
# [hello, world, --name=Seyi]
</code></pre>
<p>That <code>List&lt;String&gt; args</code> is just a list of strings. Each word or flag the user typed becomes an element in that list. Everything else you build on top of a CLI subcommands, flags, validation — is ultimately just processing this list.</p>
<h2 id="heading-core-cli-concepts-in-dart">Core CLI Concepts in Dart</h2>
<p>Before building anything, there's a set of foundational concepts that every CLI developer needs to understand. These are the building blocks that everything else sits on top of.</p>
<h3 id="heading-stdout-stderr-and-stdin">stdout, stderr, and stdin</h3>
<p>Most developers use <code>print()</code> for all output when they start building CLIs. That works for learning but it's incorrect in production.</p>
<p>There are two separate output streams in a terminal program:</p>
<ul>
<li><p><code>stdout</code> — regular output, meant for the user</p>
</li>
<li><p><code>stderr</code> — error output, meant for diagnostic messages and failures</p>
</li>
</ul>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Error: no arguments provided');
    exit(1);
  }

  stdout.writeln('Processing: ${args[0]}');
}
</code></pre>
<p>Keeping these separate matters because users can redirect stdout to a file without errors polluting it:</p>
<pre><code class="language-bash">dart run bin/tool.dart &gt; output.txt
# Errors still appear in the terminal
# Normal output goes cleanly to the file
</code></pre>
<p>Tools like <code>git</code>, <code>flutter</code>, and <code>curl</code> all do this correctly. Your CLI should too.</p>
<p><code>stdin</code> is the third stream — reading input from the user interactively at runtime:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  stdout.write('Enter your name: ');
  final name = stdin.readLineSync();

  if (name == null || name.trim().isEmpty) {
    stderr.writeln('Error: no name provided');
    exit(1);
  }

  stdout.writeln('Hello, $name!');
}
</code></pre>
<p><code>stdout.write</code> (without <code>ln</code>) keeps the cursor on the same line so the user types right after the prompt. <code>stdin.readLineSync()</code> blocks until the user presses Enter and returns the typed string, or <code>null</code> if the stream closes unexpectedly. Always handle the null case.</p>
<h3 id="heading-exit-codes">Exit Codes</h3>
<p>Every program returns an exit code when it finishes. This is how the shell – and any script or CI system calling your tool – knows whether it succeeded or failed.</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Error: please provide an argument');
    exit(1); // failure
  }

  stdout.writeln('Done');
  exit(0); // success — also the default if you don't call exit()
}
</code></pre>
<p>The conventions are:</p>
<ul>
<li><p><code>0</code> — success</p>
</li>
<li><p><code>1</code> — general failure</p>
</li>
<li><p><code>2</code> — incorrect usage (wrong arguments, missing flags)</p>
</li>
</ul>
<p>Exit codes are critical when your CLI is called inside shell scripts or GitHub Actions workflows. A non-zero exit code stops a pipeline immediately. That's exactly the behaviour you want from a quality gate or a validation step.</p>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Your CLI can read environment variables set in the user's shell:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  final token = Platform.environment['API_TOKEN'];

  if (token == null) {
    stderr.writeln('Error: API_TOKEN environment variable is not set');
    exit(1);
  }

  stdout.writeln('Token found — proceeding...');
}
</code></pre>
<p>Set it in the terminal and run:</p>
<pre><code class="language-bash">export API_TOKEN=mytoken123
dart run bin/tool.dart
# Token found — proceeding...
</code></pre>
<p>This pattern is essential for CLI tools that interact with APIs, cloud services, or CI environments where credentials should never be hardcoded.</p>
<h3 id="heading-file-and-directory-operations">File and Directory Operations</h3>
<p>Many CLI tools read from or write to the file system. Dart's <code>dart:io</code> library covers everything you need:</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: tool &lt;filename&gt;');
    exit(2);
  }

  final file = File(args[0]);

  if (!file.existsSync()) {
    stderr.writeln('Error: "${args[0]}" not found');
    exit(1);
  }

  final contents = file.readAsStringSync();
  stdout.writeln(contents);

  final output = File('output.txt');
  output.writeAsStringSync('Processed:\n$contents');
  stdout.writeln('Written to output.txt');
}
</code></pre>
<p>Working with directories:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  // Where the command was run from
  final cwd = Directory.current.path;
  stdout.writeln('Working directory: $cwd');

  // Create a directory relative to current location
  final dir = Directory('$cwd/generated');

  if (!dir.existsSync()) {
    dir.createSync(recursive: true);
    stdout.writeln('Created: ${dir.path}');
  } else {
    stdout.writeln('Already exists: ${dir.path}');
  }
}
</code></pre>
<p>The <code>recursive: true</code> flag on <code>createSync</code> means it creates all intermediate directories — equivalent to <code>mkdir -p</code> in bash.</p>
<h3 id="heading-running-external-processes">Running External Processes</h3>
<p>One of the most powerful things a CLI can do is call other programs. Your Dart CLI can run <code>git</code>, <code>flutter</code>, <code>dart</code>, or any shell command programmatically:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  // Run a command and wait for it to finish
  final result = await Process.run('dart', ['pub', 'get']);

  stdout.write(result.stdout);

  if (result.exitCode != 0) {
    stderr.write(result.stderr);
    exit(result.exitCode);
  }

  stdout.writeln('Dependencies installed successfully');
}
</code></pre>
<p>For long-running commands where you want output to stream live as it happens:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  final process = await Process.start('flutter', ['build', 'apk']);

  // Pipe output directly to the terminal in real time
  process.stdout.pipe(stdout);
  process.stderr.pipe(stderr);

  final exitCode = await process.exitCode;
  exit(exitCode);
}
</code></pre>
<p><code>Process.run</code> — waits for completion, returns all output at once. Use for short commands.</p>
<p><code>Process.start</code> — streams output live as it arrives. Use for long-running commands where the user needs to see progress.</p>
<h3 id="heading-platform-detection">Platform Detection</h3>
<p>Sometimes your CLI needs to behave differently depending on the operating system it is running on:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  if (Platform.isWindows) {
    stdout.writeln('Running on Windows');
  } else if (Platform.isMacOS) {
    stdout.writeln('Running on macOS');
  } else if (Platform.isLinux) {
    stdout.writeln('Running on Linux');
  }

  // Useful for path handling across operating systems
  stdout.writeln(Platform.pathSeparator); // \ on Windows, / elsewhere
  stdout.writeln(Platform.operatingSystem); // 'macos', 'linux', 'windows'
}
</code></pre>
<p>This matters when your CLI creates files, resolves paths, or calls shell commands that differ between operating systems.</p>
<h3 id="heading-async-in-cli">Async in CLI</h3>
<p>Dart CLIs support <code>async/await</code> natively. Any <code>main</code> function can be made async:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  stdout.writeln('Starting...');

  await Future.delayed(const Duration(seconds: 1)); // simulating async work

  stdout.writeln('Done');
}
</code></pre>
<p>Any operation involving file I/O, HTTP requests, or spawning processes will be asynchronous. Get comfortable with async <code>main</code> functions early — you'll use them constantly.</p>
<h2 id="heading-setting-up-your-dart-cli-project">Setting Up Your Dart CLI Project</h2>
<p>Create a new Dart console project:</p>
<pre><code class="language-bash">dart create -t console my_cli_tool
cd my_cli_tool
</code></pre>
<p>This generates a clean structure:</p>
<pre><code class="language-plaintext">my_cli_tool/
  bin/
    my_cli_tool.dart    ← entry point
  lib/                  ← shared library code
  test/                 ← tests
  pubspec.yaml
  README.md
</code></pre>
<p>The <code>bin/</code> directory is where your executable entry point lives. The <code>lib/</code> directory is where you put everything else — commands, utilities, models — that <code>bin/</code> imports and uses.</p>
<p>Open <code>pubspec.yaml</code>. You'll need to add an <code>executables</code> block before publishing:</p>
<pre><code class="language-yaml">name: my_cli_tool
description: A sample CLI tool built with Dart
version: 1.0.0

environment:
  sdk: '&gt;=3.0.0 &lt;4.0.0'

executables:
  my_cli_tool: my_cli_tool  # executable name: bin file name

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>The <code>executables</code> block is what makes <code>dart pub global activate my_cli_tool</code> work. It tells Dart which script in <code>bin/</code> to expose as a runnable command after installation.</p>
<h2 id="heading-cli-1-hello-cli-the-fundamentals">CLI 1 — Hello CLI: The Fundamentals</h2>
<p>This first CLI uses pure Dart — no packages. The goal is to get comfortable with args, subcommands, input validation, and exit codes before introducing any external dependencies.</p>
<p>Replace the contents of <code>bin/my_cli_tool.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    printHelp();
    exit(0);
  }

  final command = args[0];

  switch (command) {
    case 'greet':
      handleGreet(args.sublist(1));
    case 'time':
      handleTime();
    case 'echo':
      handleEcho(args.sublist(1));
    case 'help':
      printHelp();
    default:
      stderr.writeln('Unknown command: "$command"');
      stderr.writeln('Run "mytool help" to see available commands.');
      exit(1);
  }
}

void handleGreet(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: mytool greet &lt;name&gt;');
    exit(2);
  }

  final name = args[0];
  stdout.writeln('Hello, $name! Welcome to your first Dart CLI.');
}

void handleTime() {
  final now = DateTime.now();
  stdout.writeln(
    'Current time: ${now.hour.toString().padLeft(2, '0')}:'
    '${now.minute.toString().padLeft(2, '0')}:'
    '${now.second.toString().padLeft(2, '0')}',
  );
}

void handleEcho(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: mytool echo &lt;message&gt;');
    exit(2);
  }

  stdout.writeln(args.join(' '));
}

void printHelp() {
  stdout.writeln('''
mytool — a simple Dart CLI

Usage:
  mytool &lt;command&gt; [arguments]

Commands:
  greet &lt;name&gt;      Greet someone by name
  time              Show the current time
  echo &lt;message&gt;    Echo a message back to the terminal
  help              Show this help message

Examples:
  mytool greet Seyi
  mytool echo "Hello from the terminal"
  mytool time
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/my_cli_tool.dart help

dart run bin/my_cli_tool.dart greet Seyi
# Hello, Seyi! Welcome to your first Dart CLI.

dart run bin/my_cli_tool.dart time
# Current time: 14:32:10

dart run bin/my_cli_tool.dart echo "Dart CLIs are powerful"
# Dart CLIs are powerful

dart run bin/my_cli_tool.dart unknown
# Unknown command: "unknown"
# Run "mytool help" to see available commands.
</code></pre>
<p>Three things this CLI demonstrates that are worth internalising:</p>
<ol>
<li><p><strong>Subcommands are just a switch on</strong> <code>args[0]</code><strong>.</strong> The pattern is simple and scalable — add a new <code>case</code> to add a new command.</p>
</li>
<li><p><code>args.sublist(1)</code> <strong>passes remaining args to the handler.</strong> When <code>greet</code> receives <code>['greet', 'Seyi']</code>, it calls <code>handleGreet(['Seyi'])</code> — clean and isolated.</p>
</li>
<li><p><strong>Every error path has a message and a non-zero exit code.</strong> The user always knows what went wrong and what to do next.</p>
</li>
</ol>
<h2 id="heading-cli-2-darttodo-a-terminal-task-manager">CLI 2 — dart_todo: A Terminal Task Manager</h2>
<p>This CLI introduces the <code>args</code> package, JSON file persistence, and structured terminal output. It's meaningfully more complex than CLI 1 and reflects real patterns you will use in production tools.</p>
<h3 id="heading-introducing-the-args-package">Introducing the args Package</h3>
<p>Manually parsing <code>List&lt;String&gt; args</code> works for simple cases, but breaks down quickly when you add flags like <code>--priority=high</code>, boolean options like <code>--done</code>, or commands with multiple optional arguments.</p>
<p>The <code>args</code> package handles all of that cleanly.</p>
<p>Add it to your <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">dependencies:
  args: ^2.4.2
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">dart pub get
</code></pre>
<p>The core concept in <code>args</code> is the <code>ArgParser</code>. You define what your CLI accepts, and <code>args</code> handles parsing, validation, and generating help text automatically:</p>
<pre><code class="language-dart">import 'package:args/args.dart';

void main(List&lt;String&gt; arguments) {
  final parser = ArgParser()
    ..addCommand('add')
    ..addCommand('list')
    ..addFlag('help', abbr: 'h', negatable: false);

  final results = parser.parse(arguments);

  if (results['help'] as bool) {
    print(parser.usage);
    return;
  }
}
</code></pre>
<p>For more complex CLIs with subcommands that each have their own flags, use <code>ArgParser</code> per command:</p>
<pre><code class="language-dart">final parser = ArgParser();

final addCommand = ArgParser()
  ..addOption('priority', abbr: 'p', defaultsTo: 'normal');

parser.addCommand('add', addCommand);
</code></pre>
<h3 id="heading-building-darttodo">Building dart_todo</h3>
<p>Create a fresh project:</p>
<pre><code class="language-bash">dart create -t console dart_todo
cd dart_todo
</code></pre>
<p>Update <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">name: dart_todo
description: A terminal task manager built with Dart
version: 1.0.0

environment:
  sdk: '&gt;=3.0.0 &lt;4.0.0'

executables:
  dart_todo: dart_todo

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>Run <code>dart pub get</code>.</p>
<p>Create the folder structure:</p>
<pre><code class="language-plaintext">dart_todo/
  bin/
    dart_todo.dart
  lib/
    models/
      task.dart
    storage/
      task_storage.dart
    commands/
      add_command.dart
      list_command.dart
      complete_command.dart
      delete_command.dart
      clear_command.dart
  pubspec.yaml
</code></pre>
<h4 id="heading-step-1-the-task-model-libmodelstaskdart">Step 1 — The Task Model (<code>lib/models/task.dart</code>)</h4>
<pre><code class="language-dart">class Task {
  final int id;
  final String title;
  final String priority;
  final bool isComplete;
  final DateTime createdAt;

  Task({
    required this.id,
    required this.title,
    required this.priority,
    this.isComplete = false,
    required this.createdAt,
  });

  Task copyWith({bool? isComplete}) {
    return Task(
      id: id,
      title: title,
      priority: priority,
      isComplete: isComplete ?? this.isComplete,
      createdAt: createdAt,
    );
  }

  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'title': title,
        'priority': priority,
        'isComplete': isComplete,
        'createdAt': createdAt.toIso8601String(),
      };

  factory Task.fromJson(Map&lt;String, dynamic&gt; json) =&gt; Task(
        id: json['id'] as int,
        title: json['title'] as String,
        priority: json['priority'] as String,
        isComplete: json['isComplete'] as bool,
        createdAt: DateTime.parse(json['createdAt'] as String),
      );
}
</code></pre>
<h4 id="heading-step-2-storage-libstoragetaskstoragedart">Step 2 — Storage (<code>lib/storage/task_storage.dart</code>)</h4>
<p>This class handles reading and writing tasks to a local JSON file so they persist between CLI runs:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'dart:io';

import '../models/task.dart';

class TaskStorage {
  static final _file = File(
    '${Platform.environment['HOME'] ?? Directory.current.path}/.dart_todo.json',
  );

  static List&lt;Task&gt; loadAll() {
    if (!_file.existsSync()) return [];

    try {
      final content = _file.readAsStringSync();
      final List&lt;dynamic&gt; json = jsonDecode(content) as List&lt;dynamic&gt;;
      return json
          .map((e) =&gt; Task.fromJson(e as Map&lt;String, dynamic&gt;))
          .toList();
    } catch (_) {
      return [];
    }
  }

  static void saveAll(List&lt;Task&gt; tasks) {
    final json = jsonEncode(tasks.map((t) =&gt; t.toJson()).toList());
    _file.writeAsStringSync(json);
  }
}
</code></pre>
<p>Tasks are stored in a hidden JSON file in the user's home directory — a common pattern for CLI tools that need lightweight local persistence.</p>
<h4 id="heading-step-3-commands">Step 3 — Commands</h4>
<p><code>lib/commands/add_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../models/task.dart';
import '../storage/task_storage.dart';

void runAdd(List&lt;String&gt; args, String priority) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo add &lt;title&gt; [--priority=high|normal|low]');
    exit(2);
  }

  final title = args.join(' ');
  final tasks = TaskStorage.loadAll();

  final newTask = Task(
    id: tasks.isEmpty ? 1 : tasks.last.id + 1,
    title: title,
    priority: priority,
    createdAt: DateTime.now(),
  );

  tasks.add(newTask);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Added task #\({newTask.id}: "\)title" [$priority]');
}
</code></pre>
<p><code>lib/commands/list_command.dart</code>:</p>
<pre><code class="language-cpp">import 'dart:io';

import '../storage/task_storage.dart';

void runList() {
  final tasks = TaskStorage.loadAll();

  if (tasks.isEmpty) {
    stdout.writeln('No tasks yet. Add one with: dart_todo add &lt;title&gt;');
    return;
  }

  stdout.writeln('');
  stdout.writeln('  ID   Status      Priority   Title');
  stdout.writeln('  ───  ──────────  ─────────  ────────────────────────');

  for (final task in tasks) {
    final status = task.isComplete ? 'done  ' : 'pending';
    final id = task.id.toString().padRight(4);
    final priority = task.priority.padRight(9);
    stdout.writeln('  \(id \)status  \(priority  \){task.title}');
  }

  stdout.writeln('');
}
</code></pre>
<p><code>lib/commands/complete_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runComplete(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo complete &lt;id&gt;');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.writeln('Error: "${args[0]}" is not a valid task ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) =&gt; t.id == id);

  if (index == -1) {
    stderr.writeln('Error: No task found with ID $id');
    exit(1);
  }

  if (tasks[index].isComplete) {
    stdout.writeln('Task #$id is already complete.');
    return;
  }

  tasks[index] = tasks[index].copyWith(isComplete: true);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Task #\(id marked as complete: "\){tasks[index].title}"');
}
</code></pre>
<p><code>lib/commands/delete_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runDelete(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo delete &lt;id&gt;');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.writeln('Error: "${args[0]}" is not a valid task ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) =&gt; t.id == id);

  if (index == -1) {
    stderr.writeln('Error: No task found with ID $id');
    exit(1);
  }

  final title = tasks[index].title;
  tasks.removeAt(index);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Deleted task #\(id: "\)title"');
}
</code></pre>
<p><code>lib/commands/clear_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runClear() {
  stdout.write('Are you sure you want to delete all tasks? (y/N): ');
  final input = stdin.readLineSync()?.trim().toLowerCase();

  if (input != 'y') {
    stdout.writeln('Cancelled.');
    return;
  }

  TaskStorage.saveAll([]);
  stdout.writeln('All tasks cleared.');
}
</code></pre>
<h4 id="heading-step-4-entry-point-bindarttododart">Step 4 — Entry Point (<code>bin/dart_todo.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:args/args.dart';

import '../lib/commands/add_command.dart';
import '../lib/commands/clear_command.dart';
import '../lib/commands/complete_command.dart';
import '../lib/commands/delete_command.dart';
import '../lib/commands/list_command.dart';

void main(List&lt;String&gt; arguments) {
  final parser = ArgParser();

  // Add subcommand parsers
  final addParser = ArgParser()
    ..addOption(
      'priority',
      abbr: 'p',
      defaultsTo: 'normal',
      allowed: ['high', 'normal', 'low'],
      help: 'Task priority level',
    );

  parser
    ..addCommand('add', addParser)
    ..addCommand('list')
    ..addCommand('complete')
    ..addCommand('delete')
    ..addCommand('clear')
    ..addFlag('help', abbr: 'h', negatable: false, help: 'Show help');

  ArgResults results;

  try {
    results = parser.parse(arguments);
  } catch (e) {
    stderr.writeln('Error: $e');
    stderr.writeln(parser.usage);
    exit(2);
  }

  if (results['help'] as bool || results.command == null) {
    printHelp(parser);
    exit(0);
  }

  final command = results.command!;

  switch (command.name) {
    case 'add':
      runAdd(command.rest, command['priority'] as String);
    case 'list':
      runList();
    case 'complete':
      runComplete(command.rest);
    case 'delete':
      runDelete(command.rest);
    case 'clear':
      runClear();
    default:
      stderr.writeln('Unknown command: "${command.name}"');
      exit(1);
  }
}

void printHelp(ArgParser parser) {
  stdout.writeln('''
dart_todo — a terminal task manager

Usage:
  dart_todo &lt;command&gt; [arguments]

Commands:
  add &lt;title&gt;        Add a new task
    -p, --priority   Priority: high, normal, low (default: normal)
  list               List all tasks
  complete &lt;id&gt;      Mark a task as complete
  delete &lt;id&gt;        Delete a task
  clear              Delete all tasks

Examples:
  dart_todo add "Write the CLI article" --priority=high
  dart_todo list
  dart_todo complete 1
  dart_todo delete 2
  dart_todo clear
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/dart_todo.dart add "Write the CLI article" --priority=high
# Added task #1: "Write the CLI article" [high]

dart run bin/dart_todo.dart add "Review PR comments"
# Added task #2: "Review PR comments" [normal]

dart run bin/dart_todo.dart list
#   ID   Status      Priority   Title
#   ───  ──────────  ─────────  ────────────────────────
#   1    ⬜ pending  high       Write the CLI article
#   2    ⬜ pending  normal     Review PR comments

dart run bin/dart_todo.dart complete 1
# Task #1 marked as complete: "Write the CLI article"

dart run bin/dart_todo.dart delete 2
# Deleted task #2: "Review PR comments"
</code></pre>
<p><code>dart_todo</code> demonstrates the patterns that form the backbone of almost every real CLI tool — argument parsing with <code>args</code>, JSON persistence, interactive prompts, structured output, and clean error handling across every command.</p>
<h2 id="heading-cli-3-darthttp-a-lightweight-api-request-runner">CLI 3 — dart_http: A Lightweight API Request Runner</h2>
<p>This is the most complex CLI in this article – and the most immediately useful. <code>dart_http</code> lets developers make HTTP requests directly from the terminal, with pretty-printed JSON responses, response metadata, header support, and the ability to save responses to a file.</p>
<pre><code class="language-bash">dart_http get https://jsonplaceholder.typicode.com/users/1
dart_http post https://jsonplaceholder.typicode.com/posts --body='{"title":"Hello"}'
dart_http get https://jsonplaceholder.typicode.com/users --save=users.json
dart_http get https://api.example.com/me --header="Authorization: Bearer mytoken"
</code></pre>
<h3 id="heading-building-darthttp">Building dart_http</h3>
<p>Create the project:</p>
<pre><code class="language-bash">dart create -t console dart_http
cd dart_http
</code></pre>
<p>Update <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">name: dart_http
description: A lightweight API request runner for the terminal
version: 1.0.0

environment:
  sdk: '&gt;=3.0.0 &lt;4.0.0'

executables:
  dart_http: dart_http

dependencies:
  args: ^2.4.2
  http: ^1.2.1

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>Run <code>dart pub get</code>.</p>
<p>Project structure:</p>
<pre><code class="language-plaintext">dart_http/
  bin/
    dart_http.dart
  lib/
    runner/
      request_runner.dart
    printer/
      response_printer.dart
    utils/
      headers_parser.dart
  pubspec.yaml
</code></pre>
<h4 id="heading-step-1-headers-parser-libutilsheadersparserdart">Step 1 — Headers Parser (<code>lib/utils/headers_parser.dart</code>)</h4>
<pre><code class="language-dart">Map&lt;String, String&gt; parseHeaders(List&lt;String&gt; rawHeaders) {
  final headers = &lt;String, String&gt;{};

  for (final header in rawHeaders) {
    final index = header.indexOf(':');
    if (index == -1) continue;

    final key = header.substring(0, index).trim();
    final value = header.substring(index + 1).trim();
    headers[key] = value;
  }

  return headers;
}
</code></pre>
<h4 id="heading-step-2-response-printer-libprinterresponseprinterdart">Step 2 — Response Printer (<code>lib/printer/response_printer.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:convert';
import 'dart:io';

void printResponse({
  required int statusCode,
  required String body,
  required int durationMs,
  required int bodyBytes,
}) {
  final statusLabel = _statusLabel(statusCode);
  final size = _formatSize(bodyBytes);

  stdout.writeln('');
  stdout.writeln('\(statusLabel | \){durationMs}ms | $size');
  stdout.writeln('─' * 50);

  try {
    final decoded = jsonDecode(body);
    const encoder = JsonEncoder.withIndent('  ');
    stdout.writeln(encoder.convert(decoded));
  } catch (_) {
    // Not JSON — print as plain text
    stdout.writeln(body);
  }

  stdout.writeln('');
}

String _statusLabel(int code) {
  if (code &gt;= 200 &amp;&amp; code &lt; 300) return '✅ $code';
  if (code &gt;= 300 &amp;&amp; code &lt; 400) return '↪️  $code';
  if (code &gt;= 400 &amp;&amp; code &lt; 500) return '❌ $code';
  return '$code';
}

String _formatSize(int bytes) {
  if (bytes &lt; 1024) return '${bytes}b';
  if (bytes &lt; 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}kb';
  return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}mb';
}
</code></pre>
<h4 id="heading-step-3-request-runner-librunnerrequestrunnerdart">Step 3 — Request Runner (<code>lib/runner/request_runner.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:http/http.dart' as http;

import '../printer/response_printer.dart';

Future&lt;void&gt; runRequest({
  required String method,
  required String url,
  required Map&lt;String, String&gt; headers,
  String? body,
  String? saveToFile,
}) async {
  final uri = Uri.tryParse(url);

  if (uri == null) {
    stderr.writeln('Error: "$url" is not a valid URL');
    exit(1);
  }

  stdout.writeln('→ \({method.toUpperCase()} \)url');

  http.Response response;
  final stopwatch = Stopwatch()..start();

  try {
    switch (method.toLowerCase()) {
      case 'get':
        response = await http.get(uri, headers: headers);
      case 'post':
        response = await http.post(uri, headers: headers, body: body);
      case 'put':
        response = await http.put(uri, headers: headers, body: body);
      case 'patch':
        response = await http.patch(uri, headers: headers, body: body);
      case 'delete':
        response = await http.delete(uri, headers: headers);
      default:
        stderr.writeln('Error: unsupported method "$method"');
        exit(2);
    }
  } catch (e) {
    stderr.writeln('Error: request failed — $e');
    exit(1);
  }

  stopwatch.stop();

  printResponse(
    statusCode: response.statusCode,
    body: response.body,
    durationMs: stopwatch.elapsedMilliseconds,
    bodyBytes: response.bodyBytes.length,
  );

  if (saveToFile != null) {
    final file = File(saveToFile);
    file.writeAsStringSync(response.body);
    stdout.writeln('Response saved to $saveToFile');
  }
}
</code></pre>
<h4 id="heading-step-4-entry-point-bindarthttpdart">Step 4 — Entry Point (<code>bin/dart_http.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:args/args.dart';

import '../lib/runner/request_runner.dart';
import '../lib/utils/headers_parser.dart';

void main(List&lt;String&gt; arguments) async {
  final parser = ArgParser();

  for (final method in ['get', 'post', 'put', 'patch', 'delete']) {
    final commandParser = ArgParser()
      ..addMultiOption('header', abbr: 'H', help: 'Request header (repeatable)')
      ..addOption('body', abbr: 'b', help: 'Request body (for POST/PUT/PATCH)')
      ..addOption('save', abbr: 's', help: 'Save response body to a file');

    parser.addCommand(method, commandParser);
  }

  parser.addFlag('help', abbr: 'h', negatable: false, help: 'Show help');

  ArgResults results;

  try {
    results = parser.parse(arguments);
  } catch (e) {
    stderr.writeln('Error: $e');
    printHelp();
    exit(2);
  }

  if (results['help'] as bool || results.command == null) {
    printHelp();
    exit(0);
  }

  final command = results.command!;
  final method = command.name!;
  final rest = command.rest;

  if (rest.isEmpty) {
    stderr.writeln('Error: please provide a URL');
    stderr.writeln('Usage: dart_http $method &lt;url&gt;');
    exit(2);
  }

  final url = rest[0];
  final rawHeaders = command['header'] as List&lt;String&gt;;
  final body = command['body'] as String?;
  final saveToFile = command['save'] as String?;

  final headers = parseHeaders(rawHeaders);

  // Default Content-Type for requests with a body
  if (body != null &amp;&amp; !headers.containsKey('Content-Type')) {
    headers['Content-Type'] = 'application/json';
  }

  await runRequest(
    method: method,
    url: url,
    headers: headers,
    body: body,
    saveToFile: saveToFile,
  );
}

void printHelp() {
  stdout.writeln('''
dart_http — a lightweight API request runner

Usage:
  dart_http &lt;method&gt; &lt;url&gt; [options]

Methods:
  get       Send a GET request
  post      Send a POST request
  put       Send a PUT request
  patch     Send a PATCH request
  delete    Send a DELETE request

Options:
  -H, --header    Add a request header (repeatable)
  -b, --body      Request body (JSON string)
  -s, --save      Save response body to a file
  -h, --help      Show this help message

Examples:
  dart_http get https://jsonplaceholder.typicode.com/users
  dart_http get https://api.example.com/me --header="Authorization: Bearer token"
  dart_http post https://api.example.com/posts --body=\'{"title":"Hello"}\'
  dart_http get https://api.example.com/users --save=users.json
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users/1

# → GET https://jsonplaceholder.typicode.com/users/1
# 200 | 87ms | 510b
# ──────────────────────────────────────────────────
# {
#   "id": 1,
#   "name": "Leanne Graham",
#   "username": "Bret",
#   "email": "Sincere@april.biz"
# }

dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users --save=users.json
# → GET https://jsonplaceholder.typicode.com/users
# 200 | 143ms | 5.3kb
# ──────────────────────────────────────────────────
# [ ... ]
# Response saved to users.json

dart run bin/dart_http.dart post https://jsonplaceholder.typicode.com/posts \
  --body='{"title":"Hello from dart_http","userId":1}'
# → POST https://jsonplaceholder.typicode.com/posts
# 201 | 312ms | 72b
</code></pre>
<h2 id="heading-adding-color-and-polish-to-your-cli">Adding Color and Polish to Your CLI</h2>
<p>The CLIs above are functional, but terminal output can be made significantly more readable with color. The <code>ansi_styles</code> package provides ANSI escape code support for coloring text in the terminal.</p>
<p>Add it to <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">dependencies:
  ansi_styles: ^0.3.0
</code></pre>
<p>Using it:</p>
<pre><code class="language-dart">import 'package:ansi_styles/ansi_styles.dart';

stdout.writeln(AnsiStyles.green('✅ Success'));
stdout.writeln(AnsiStyles.red('❌ Error: something went wrong'));
stdout.writeln(AnsiStyles.yellow('⚠️  Warning: check your config'));
stdout.writeln(AnsiStyles.bold('dart_http — API request runner'));
stdout.writeln(AnsiStyles.cyan('→ GET https://api.example.com/users'));
</code></pre>
<p>Apply color intentionally and consistently:</p>
<ul>
<li><p><strong>Green</strong> — success states, completed operations</p>
</li>
<li><p><strong>Red</strong> — errors and failures</p>
</li>
<li><p><strong>Yellow</strong> — warnings and non-blocking issues</p>
</li>
<li><p><strong>Cyan</strong> — informational output, URLs, paths</p>
</li>
<li><p><strong>Bold</strong> — headers, tool names, important values</p>
</li>
</ul>
<p>Avoid coloring everything. Color loses meaning when it is everywhere. Use it to draw the user's eye to what actually matters.</p>
<h2 id="heading-testing-your-cli-tool">Testing Your CLI Tool</h2>
<p>CLI tools are testable, and they should be tested. The most reliable approach is to test the logic inside your commands directly — not the terminal output formatting, but the behaviour.</p>
<p>Add <code>test</code> to your dev dependencies if it's not already there:</p>
<pre><code class="language-yaml">dev_dependencies:
  test: ^1.24.0
</code></pre>
<p><strong>Testing command logic:</strong></p>
<pre><code class="language-dart">import 'package:test/test.dart';

import '../lib/models/task.dart';

void main() {
  group('Task model', () {
    test('copyWith updates isComplete correctly', () {
      final task = Task(
        id: 1,
        title: 'Write tests',
        priority: 'high',
        createdAt: DateTime.now(),
      );

      final completed = task.copyWith(isComplete: true);

      expect(completed.isComplete, isTrue);
      expect(completed.title, equals('Write tests'));
      expect(completed.id, equals(1));
    });

    test('toJson and fromJson round-trips correctly', () {
      final task = Task(
        id: 2,
        title: 'Ship the tool',
        priority: 'normal',
        createdAt: DateTime.parse('2025-01-01T00:00:00.000'),
      );

      final json = task.toJson();
      final restored = Task.fromJson(json);

      expect(restored.id, equals(task.id));
      expect(restored.title, equals(task.title));
      expect(restored.priority, equals(task.priority));
    });
  });
}
</code></pre>
<p><strong>Testing the headers parser:</strong></p>
<pre><code class="language-dart">import 'package:test/test.dart';

import '../lib/utils/headers_parser.dart';

void main() {
  group('parseHeaders', () {
    test('parses a single header correctly', () {
      final result = parseHeaders(['Authorization: Bearer mytoken']);
      expect(result['Authorization'], equals('Bearer mytoken'));
    });

    test('parses multiple headers', () {
      final result = parseHeaders([
        'Authorization: Bearer token',
        'Accept: application/json',
      ]);
      expect(result.length, equals(2));
      expect(result['Accept'], equals('application/json'));
    });

    test('ignores malformed headers without a colon', () {
      final result = parseHeaders(['malformed-header']);
      expect(result.isEmpty, isTrue);
    });
  });
}
</code></pre>
<p>Run your tests:</p>
<pre><code class="language-bash">dart test
</code></pre>
<h2 id="heading-deploying-and-distributing-your-cli">Deploying and Distributing Your CLI</h2>
<p>Building a CLI tool is half the work. Getting it into the hands of developers is the other half. There are five distribution paths available, each suited to a different use case.</p>
<h3 id="heading-mode-1-pubdev-public-package-distribution">Mode 1: pub.dev — Public Package Distribution</h3>
<p>Publishing to pub.dev makes your tool installable by anyone in the Dart and Flutter community with a single command.</p>
<h4 id="heading-prepare-your-package">Prepare your package:</h4>
<p>Your <code>pubspec.yaml</code> needs to be complete:</p>
<pre><code class="language-yaml">name: dart_http
description: A lightweight API request runner for Dart developers.
version: 1.0.0
homepage: https://github.com/yourname/dart_http

environment:
  sdk: '&gt;=3.0.0 &lt;4.0.0'

executables:
  dart_http: dart_http
</code></pre>
<p>The <code>executables</code> block is critical. It tells pub.dev which script in <code>bin/</code> to expose as a runnable command.</p>
<p>You also need:</p>
<ul>
<li><p><code>README.md</code> — what the tool does, how to install it, usage examples</p>
</li>
<li><p><code>CHANGELOG.md</code> — version history</p>
</li>
<li><p><code>LICENSE</code> — an open source license (MIT is standard)</p>
</li>
</ul>
<h4 id="heading-validate-before-publishing">Validate before publishing:</h4>
<pre><code class="language-bash">dart pub publish --dry-run
</code></pre>
<p>This runs all validation checks without actually publishing. Fix any warnings before proceeding.</p>
<h4 id="heading-publish">Publish:</h4>
<pre><code class="language-bash">dart pub publish
</code></pre>
<p>You will be prompted to authenticate with your pub.dev account. Once published, your tool is available globally:</p>
<pre><code class="language-bash">dart pub global activate dart_http
dart_http get https://api.example.com/users
</code></pre>
<h3 id="heading-mode-2-local-path-activation">Mode 2: Local Path Activation</h3>
<p>For internal team tools that you don't want to publish publicly, activate directly from a local or cloned repository:</p>
<pre><code class="language-bash">dart pub global activate --source path /path/to/dart_http
</code></pre>
<p>Any developer on the team clones the repo and runs this command once. The tool is then available globally in their terminal without needing a pub.dev publish.</p>
<p>This is the right distribution mode for:</p>
<ul>
<li><p>Internal company tooling</p>
</li>
<li><p>Tools that depend on private packages</p>
</li>
<li><p>Work-in-progress tools shared within a team before a public release</p>
</li>
</ul>
<h3 id="heading-mode-3-compiled-binary-via-github-releases">Mode 3: Compiled Binary via GitHub Releases</h3>
<p>Dart can compile to a self-contained native executable — no Dart SDK required on the target machine. This makes your tool accessible to developers outside the Dart ecosystem.</p>
<h4 id="heading-compile">Compile:</h4>
<pre><code class="language-bash"># macOS
dart compile exe bin/dart_http.dart -o dist/dart_http-macos

# Linux
dart compile exe bin/dart_http.dart -o dist/dart_http-linux

# Windows
dart compile exe bin/dart_http.dart -o dist/dart_http-windows.exe
</code></pre>
<p>The compiled binary is fully self-contained. Copy it to any machine and run it — no Dart installation needed.</p>
<h4 id="heading-automate-with-github-actions">Automate with GitHub Actions:</h4>
<p>Create <code>.github/workflows/release.yml</code>:</p>
<pre><code class="language-yaml">name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3

      - uses: dart-lang/setup-dart@v1
        with:
          sdk: stable

      - name: Install dependencies
        run: dart pub get

      - name: Compile binary
        run: |
          mkdir -p dist
          dart compile exe bin/dart_http.dart -o dist/dart_http-${{ runner.os }}

      - name: Upload binary to release
        uses: softprops/action-gh-release@v1
        with:
          files: dist/dart_http-${{ runner.os }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p>Every time you push a version tag (<code>v1.0.0</code>), GitHub Actions compiles binaries for all three platforms and attaches them to the GitHub Release automatically.</p>
<h4 id="heading-write-an-install-script">Write an install script:</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

VERSION="1.0.0"
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
BINARY="dart_http-$OS"
INSTALL_DIR="/usr/local/bin"

curl -L "https://github.com/yourname/dart_http/releases/download/v\(VERSION/\)BINARY" \
  -o "$INSTALL_DIR/dart_http"

chmod +x "$INSTALL_DIR/dart_http"
echo "dart_http installed successfully"
</code></pre>
<p>Developers install it with:</p>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/yourname/dart_http/main/install.sh | bash
</code></pre>
<h3 id="heading-mode-4-homebrew-tap">Mode 4: Homebrew Tap</h3>
<p>Homebrew is the standard package manager for macOS and is widely used on Linux. A Homebrew tap makes your tool installable with <code>brew install</code> — the most familiar installation pattern for macOS developers.</p>
<h4 id="heading-create-your-tap-repository">Create your tap repository:</h4>
<p>Create a new GitHub repository named <code>homebrew-tools</code> (the <code>homebrew-</code> prefix is required by Homebrew's naming convention).</p>
<h4 id="heading-write-the-formula">Write the formula:</h4>
<p>Create <code>Formula/dart_http.rb</code> in that repository:</p>
<pre><code class="language-ruby">class DartHttp &lt; Formula
  desc "A lightweight API request runner for the terminal"
  homepage "https://github.com/yourname/dart_http"
  version "1.0.0"

  on_macos do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-macOS"
    sha256 "YOUR_SHA256_HASH_HERE"
  end

  on_linux do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-Linux"
    sha256 "YOUR_SHA256_HASH_HERE"
  end

  def install
    bin.install "dart_http-#{OS.mac? ? 'macOS' : 'Linux'}" =&gt; "dart_http"
  end

  test do
    system "#{bin}/dart_http", "--help"
  end
end
</code></pre>
<p>Generate the SHA256 hash for each binary:</p>
<pre><code class="language-bash">shasum -a 256 dist/dart_http-macOS
</code></pre>
<h4 id="heading-install-from-the-tap">Install from the tap:</h4>
<pre><code class="language-bash">brew tap yourname/tools
brew install dart_http
</code></pre>
<p>When you release a new version, update the <code>url</code> and <code>sha256</code> values in the formula and push the change. Users run <code>brew upgrade dart_http</code> to update.</p>
<h3 id="heading-mode-5-docker">Mode 5: Docker</h3>
<p>Docker distribution is best suited for CI environments, teams that standardise on containers, or tools with complex dependencies.</p>
<h4 id="heading-write-a-dockerfile">Write a Dockerfile:</h4>
<pre><code class="language-dockerfile">FROM dart:stable AS build

WORKDIR /app
COPY pubspec.* ./
RUN dart pub get

COPY . .
RUN dart compile exe bin/dart_http.dart -o /app/dart_http

FROM debian:stable-slim
COPY --from=build /app/dart_http /usr/local/bin/dart_http

ENTRYPOINT ["dart_http"]
</code></pre>
<p>This uses a multi-stage build: the first stage compiles the binary using the Dart SDK image, and the second stage copies only the binary into a minimal Debian image. The final image has no Dart SDK — just the compiled binary.</p>
<h4 id="heading-build-and-run">Build and run:</h4>
<pre><code class="language-bash">docker build -t dart_http .
docker run dart_http get https://jsonplaceholder.typicode.com/users/1
</code></pre>
<h4 id="heading-publish-to-docker-hub">Publish to Docker Hub:</h4>
<pre><code class="language-bash">docker tag dart_http yourname/dart_http:1.0.0
docker push yourname/dart_http:1.0.0
</code></pre>
<p>Users can then run your tool without installing anything locally:</p>
<pre><code class="language-bash">docker run yourname/dart_http get https://api.example.com/users
</code></pre>
<h2 id="heading-choosing-the-right-distribution-mode">Choosing the Right Distribution Mode</h2>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Best for</th>
<th>Dart SDK required</th>
</tr>
</thead>
<tbody><tr>
<td>pub.dev</td>
<td>Public Dart/Flutter developer tools</td>
<td>Yes</td>
</tr>
<tr>
<td>Local path activation</td>
<td>Internal team tools, pre-release builds</td>
<td>Yes</td>
</tr>
<tr>
<td>Compiled binary</td>
<td>Language-agnostic tools, broad adoption</td>
<td>No</td>
</tr>
<tr>
<td>Homebrew tap</td>
<td>macOS/Linux developer tools</td>
<td>No</td>
</tr>
<tr>
<td>Docker</td>
<td>CI environments, complex dependencies</td>
<td>No</td>
</tr>
</tbody></table>
<p>For most tools, the practical recommendation is:</p>
<ul>
<li><p>Start with <strong>pub.dev</strong> if your audience is Dart developers</p>
</li>
<li><p>Add <strong>compiled binary + GitHub Releases</strong> once you want broader adoption</p>
</li>
<li><p>Add a <strong>Homebrew tap</strong> when macOS developers start asking for it</p>
</li>
<li><p>Use <strong>Docker</strong> only when it is already part of your team's workflow</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've gone from understanding what a CLI is to building three progressively complex tools and distributing them across five different channels.</p>
<p>The foundational skills – <code>args</code>, <code>stdin</code>, <code>stdout</code>, <code>stderr</code>, exit codes, file I/O, and process spawning – are the same building blocks that tools like <code>flutter</code>, <code>git</code>, and <code>dart</code> themselves are built on. Everything else is composition.</p>
<p>The three CLIs we built (Hello CLI, <code>dart_todo</code>, and <code>dart_http</code>) each introduced a new layer: raw Dart fundamentals, the <code>args</code> package with JSON persistence, and real-world HTTP interaction. The distribution section ensures that whatever you build next, you have a clear path to getting it in front of the developers who will use it.</p>
<p>Dart is a powerful language for CLI development. Its strong typing, async support, native compilation, and pub.dev ecosystem make it a serious choice for building developer tooling, not just mobile apps.</p>
<p>The next step is building something that solves a real problem for you or your team, and shipping it.</p>
<p>Happy coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete Flutter CI/CD Pipeline with Codemagic: From PR Quality Gates to Automated Store Releases ]]>
                </title>
                <description>
                    <![CDATA[ If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploa ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-complete-flutter-ci-cd-pipeline-with-codemagic/</link>
                <guid isPermaLink="false">69c1dcba30a9b81e3ac436d8</guid>
                
                    <category>
                        <![CDATA[ code magic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 24 Mar 2026 00:37:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/914de6f3-5b7f-48ff-a092-1f8d095202e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploads it somewhere, and notifies the QA team. Repeat for iOS. Repeat for staging. Repeat for production.</p>
<p>And somewhere in that chain, something often goes wrong: an incorrect API key, a missed signing step, a build that worked on one machine and failed on another.</p>
<p>The solution is a properly configured CI/CD pipeline that takes that entire chain out of human hands. And in this article, we're building exactly that using Codemagic.</p>
<h2 id="heading-what-is-codemagic">What is Codemagic?</h2>
<p>Codemagic is a dedicated CI/CD platform built from the ground up specifically for mobile applications.</p>
<p>Unlike general-purpose CI platforms, Codemagic understands Flutter natively. It ships with Flutter pre-installed on its build machines, has dedicated support for Apple code signing, and integrates directly with both the Google Play Store and App Store Connect. This means less configuration noise and more focus on what actually matters , which is your deployment logic.</p>
<p>The pipeline we'll be building covers three distinct stages across both Android and iOS:</p>
<ul>
<li><p>A pull request gate that blocks unverified code from reaching your base branch</p>
</li>
<li><p>A staging pipeline that injects real environment config, builds signed artifacts, and ships them to testers via Firebase App Distribution and TestFlight</p>
</li>
<li><p>A production pipeline that obfuscates builds, uploads crash symbols to Sentry, and submits directly to the Play Store and App Store Connect</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
</li>
<li><p><a href="#heading-pr-quality-gate">PR Quality Gate</a></p>
</li>
<li><p><a href="#heading-android-pipeline">Android Pipeline</a></p>
</li>
<li><p><a href="#heading-ios-pipeline">iOS Pipeline</a></p>
</li>
<li><p><a href="#heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need the following before starting:</p>
<ul>
<li><p>A Flutter app with functional Android and iOS builds</p>
</li>
<li><p>A Codemagic account with your repository connected</p>
</li>
<li><p>A Firebase project with App Distribution set up</p>
</li>
<li><p>A Sentry project configured for your app</p>
</li>
<li><p>A Google Play Console app with at least an internal track ready</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>A Google Play service account with the necessary API permissions</p>
</li>
<li><p>Familiarity with writing Bash scripts</p>
</li>
</ul>
<h2 id="heading-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</h2>
<p>Codemagic offers a visual workflow editor for teams that prefer a GUI – but we're not using that here. The <code>codemagic.yaml</code> approach gives you version-controlled, reviewable, fully reproducible pipeline definitions that live right alongside your application code. Any change to the pipeline goes through the same PR process as any other change. That matters in a team environment.</p>
<p>The file lives at the root of your project:</p>
<pre><code class="language-plaintext">your-flutter-app/
  codemagic.yaml     
  lib/
  android/
  ios/
  scripts/
</code></pre>
<p>Codemagic detects this file when a build is triggered and executes the appropriate workflow based on the rules you define. One file, multiple workflows, all environments – no duplication.</p>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>Before writing any YAML, it helps to define exactly what the pipeline needs to do. The use case here is a team with three protected branches: <code>develop</code>, <code>staging</code>, and <code>production</code>. Each branch represents a distinct stage in the release lifecycle, and the pipeline behaves differently depending on which branch triggered it.</p>
<p>Here is how the three environments map to pipeline behaviour:</p>
<p><strong>PR into develop</strong>: When a developer raises a pull request targeting the <code>develop</code> branch, a quality gate workflow fires. It runs code formatting checks, static analysis, the full test suite, and enforces a minimum coverage threshold. The PR cannot be considered clean until all of these pass.</p>
<p><strong>Push to develop or staging</strong>: When code lands on either of these branches, the platform-specific build pipelines trigger. They detect the target branch, inject the correct environment configuration (dev or staging API keys), build signed artifacts, and distribute them to the appropriate testing channels: Firebase App Distribution for Android, TestFlight for iOS.</p>
<p><strong>Push to production</strong>: When code reaches the production branch, the pipelines switch into release mode. Builds are obfuscated, debug symbols are uploaded to Sentry for crash observability, and the final artifacts are submitted directly to the Play Store and App Store Connect.</p>
<p>Your project structure will look like this:</p>
<pre><code class="language-plaintext">codemagic.yaml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh

lib/
  core/
    env/
      env_ci.dart       
      env_ci.g.dart     
</code></pre>
<h2 id="heading-the-helper-scripts">The Helper Scripts</h2>
<p>Rather than cramming logic directly into YAML, this pipeline delegates its core operations to three Bash scripts that live in a <code>scripts/</code> folder at the project root. This keeps the YAML readable and, crucially, means you can run the exact same logic on your local machine that CI runs – eliminating an entire class of "works on my machine" issues.</p>
<p>Make all three scripts executable before committing them:</p>
<pre><code class="language-bash">chmod +x scripts/generate_config.sh
chmod +x scripts/quality_checks.sh
chmod +x scripts/upload_symbols.sh
</code></pre>
<h3 id="heading-generateconfigsh">generate_config.sh</h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile development. The strategy here avoids committing credentials entirely: a Dart file with placeholder values is committed to source control, and at build time the script replaces those placeholders with real values sourced from Codemagic's encrypted secret storage.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

# Usage: ./scripts/generate_config.sh ENV_NAME BASE_URL ENCRYPTION_KEY
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "✅ Generated config for $ENV_NAME"
</code></pre>
<p><strong>How it works:</strong></p>
<p><code>set -euo pipefail</code> enforces strict failure behaviour. <code>-e</code> exits immediately on any failed command, <code>-u</code> exits on undefined variables, and <code>-o pipefail</code> catches failures anywhere in a pipeline – not just the last command. In CI, silent failures can produce broken builds that look like they succeeded. This line prevents that.</p>
<p>The script takes three positional arguments: the environment name (<code>dev</code>, <code>staging</code>, or <code>production</code>), the API base URL, and an encryption or API key. The <code>${1:-}</code> syntax defaults to an empty string if an argument is missing, which the validation block then catches explicitly with a clear usage message and an exit code of <code>2</code> (the conventional code for incorrect usage).</p>
<p>At the heart of the script, <code>sed</code> performs three placeholder replacements in a single pass over the template file, writing the result to <code>env_ci.g.dart</code>. That generated file must be added to <code>.gitignore</code>. It only ever exists inside a running build or on a developer's local machine after they run the script manually.</p>
<p>The two Dart files involved have completely different roles:</p>
<p><code>env_ci.dart</code> – committed to source control, contains only placeholders:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.dart
class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p><code>env_ci.g.dart</code> – generated at build time, contains real values, never committed:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT
class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Add the generated file to <code>.gitignore</code>:</p>
<pre><code class="language-plaintext"># Generated environment config
lib/core/env/env_ci.g.dart
</code></pre>
<h3 id="heading-qualitycheckssh">quality_checks.sh</h3>
<p>This script defines what passing quality means for your codebase. Every check it runs is a gate: if any step fails, the script stops immediately and the build fails.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

echo "🚀 Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "✅ Quality checks passed"
</code></pre>
<p><strong>What each step does:</strong></p>
<p><code>dart format --output=none --set-exit-if-changed .</code>: checks that all Dart files are formatted correctly without modifying them. If any file doesn't match the formatter's output, the command exits with a non-zero code, failing the build. Formatting is non-negotiable here.</p>
<p><code>flutter analyze</code>: runs Dart's static analyser across the entire project. It catches null safety violations, unused imports, missing awaits, dead code, and a wide range of structural issues before they reach a reviewer's eyes.</p>
<p><code>flutter test --no-pub --coverage</code>: runs the full test suite and generates a coverage report at <code>coverage/lcov.info</code>. The <code>--no-pub</code> flag skips <code>pub get</code> since dependencies are already installed. The coverage file is used downstream to enforce a minimum threshold.</p>
<p>The <code>dart_code_metrics</code> block is deliberately optional and non-blocking (<code>|| true</code>). The tool may not be installed in every environment, and its findings are advisory rather than hard failures. You can remove the <code>|| true</code> later to make it mandatory once your team has adopted the tool.</p>
<p>The final <code>echo</code> line only executes if every step above it passed , because <code>set -e</code> would have exited the script on any earlier failure. If you see it in the logs, the branch is clean.</p>
<h3 id="heading-uploadsymbolssh">upload_symbols.sh</h3>
<p>When Flutter production builds are compiled with <code>--obfuscate</code>, stack traces in crash reports become unreadable. This script uploads the debug symbol files that Sentry needs to reverse that obfuscation and show readable crash reports.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true
sentry-cli upload-dif build/symbols || true
sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p><strong>How it works:</strong></p>
<p>The script takes a single argument: a release identifier. In practice, this is always the short Git commit SHA, passed from the workflow as <code>$(git rev-parse --short HEAD)</code>. This ties the uploaded symbols, the deployed build, and the crash reports in Sentry to the exact same commit , which is essential for production debugging.</p>
<p>If <code>sentry-cli</code> is not installed in the environment, the script exits with <code>0</code> rather than failing. This makes symbol uploads environment-aware: production machines install the CLI, development environments skip the step cleanly without breaking the build.</p>
<p>Each <code>sentry-cli</code> command uses <code>|| true</code> for resilience. Symbol uploads should never block a deployment , if the upload encounters a transient issue, the build should still succeed and the symbols can be re-uploaded manually from the stored artifacts.</p>
<p>The three commands do the following in sequence: <code>releases new</code> registers the release version in Sentry, <code>upload-dif</code> sends the debug information files from <code>build/symbols</code> (generated by <code>--split-debug-info</code>), and <code>releases finalize</code> marks the release as deployed and ready to aggregate crash reports.</p>
<h2 id="heading-the-codemagicyaml-structure">The codemagic.yaml Structure</h2>
<p>A <code>codemagic.yaml</code> file is organized around workflows. Each workflow is an independent pipeline definition with its own trigger rules, environment configuration, build scripts, and publishing targets. Multiple workflows live inside the same file under a top-level <code>workflows</code> key.</p>
<p>The skeleton looks like this:</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    # triggers on pull requests
    # runs quality checks only

  android-pipeline:
    # triggers on push to develop, staging, production
    # handles Android builds and distribution

  ios-pipeline:
    # triggers on push to develop, staging, production
    # handles iOS builds and distribution
</code></pre>
<p>Each workflow can define its own machine type, environment variables, triggering conditions, and step scripts. This is what makes a single <code>codemagic.yaml</code> powerful: you're not managing three separate files, but you still get complete isolation between pipeline stages.</p>
<h2 id="heading-pr-quality-gate">PR Quality Gate</h2>
<p>Every PR raised against <code>develop</code> must pass a quality gate before any merge is allowed. This workflow runs on Codemagic's Linux machines since it doesn't need to produce a signed artifact for any platform – it only needs to verify the code.</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    name: PR Quality Gate
    max_build_duration: 30
    instance_type: linux_x2

    triggering:
      events:
        - pull_request
      branch_patterns:
        - pattern: develop
          include: true
          source: true

    environment:
      flutter: stable

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Run quality checks
        script: ./scripts/quality_checks.sh

      - name: Enforce coverage threshold
        script: |
          COVERAGE=\((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//')
          if [ \((echo "\)COVERAGE &lt; 70" | bc) -eq 1 ]; then
            echo "Test coverage is at ${COVERAGE}% — minimum required is 70%"
            exit 1
          fi
          echo "Coverage at ${COVERAGE}% — threshold met"

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Let's walk through what each section is doing.</p>
<p><code>instance_type: linux_x2</code></p>
<p>Codemagic offers different machine types for different workloads. For a quality gate that only needs to run Dart tooling, a Linux machine is perfectly sufficient and significantly cheaper than a macOS instance. You reserve the macOS machines for builds that actually need Xcode.</p>
<p><code>triggering</code></p>
<p>This is how Codemagic decides when to run a workflow. The <code>pull_request</code> event fires whenever a PR is opened or updated. The <code>branch_patterns</code> block tells Codemagic to watch for PRs targeting <code>develop</code> specifically. The <code>source: true</code> flag means this pattern applies to the target branch of the PR, not the source branch – so any branch raising a PR into <code>develop</code> will trigger this workflow.</p>
<p><code>environment</code></p>
<p>Codemagic's Flutter-aware machines come with multiple Flutter versions available. Setting <code>flutter: stable</code> pins the workflow to the current stable channel without requiring any manual SDK installation step. This is one of the areas where Codemagic saves setup time compared to a general-purpose runner.</p>
<p><strong>Quality checks script</strong></p>
<p>The workflow delegates to <code>quality_checks.sh</code> rather than inlining commands. This keeps the YAML readable and ensures the exact same logic runs when a developer calls the script locally. The script handles formatting, analysis, and test execution internally.</p>
<p><strong>Coverage enforcement</strong></p>
<p>After the tests run, <code>lcov</code> parses the coverage report generated by <code>flutter test --coverage</code> and extracts the line coverage percentage. If it falls below 70%, the build fails with a clear message. This threshold is something your team should agree on , 70% is a reasonable starting point for most projects.</p>
<p><code>publishing</code></p>
<p>Codemagic has native email notification support built in. Rather than scripting <code>echo</code> statements into CI logs, you declare recipients directly in the workflow and Codemagic handles delivery. Both success and failure states are covered.</p>
<h2 id="heading-android-pipeline">Android Pipeline</h2>
<p>The Android workflow handles all three environments in a single workflow definition, using Codemagic's environment variable groups and conditional scripting to behave differently depending on which branch triggered the build.</p>
<pre><code class="language-yaml">  android-pipeline:
    name: Android Build &amp; Release
    max_build_duration: 60
    instance_type: linux_x2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      android_signing:
        - android_keystore
      groups:
        - staging_secrets
        - production_secrets
        - firebase_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build Android artifact
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      - name: Distribute to Firebase App Distribution
        script: |
          if [ "\(ENV" = "dev" ] || [ "\)ENV" = "staging" ]; then
            firebase appdistribution:distribute \
              build/app/outputs/bundle/release/app-release.aab \
              --app "$FIREBASE_ANDROID_APP_ID" \
              --groups "$FIREBASE_GROUPS" \
              --token "$FIREBASE_TOKEN"
          fi

      - name: Submit to Play Store
        script: |
          if [ "$ENV" = "production" ]; then
            echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" &gt; /tmp/service_account.json
            flutter pub global activate fastlane 2&gt;/dev/null || true
            fastlane supply \
              --aab build/app/outputs/bundle/release/app-release.aab \
              --json_key /tmp/service_account.json \
              --package_name com.your.package \
              --track production
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/app/outputs/bundle/release/app-release.aab
      - build/symbols/**

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here is what each section is doing and why it's designed this way.</p>
<p><code>android_signing</code></p>
<p>This is one of Codemagic's most valuable features. Instead of manually decoding a Base64 keystore and writing it to disk inside a script, you upload your keystore file directly to Codemagic's encrypted key storage under Teams → Code signing identities → Android keystores. You give it a reference name – <code>android_keystore</code> in this case – and Codemagic handles decoding, placement, and <code>key.properties</code> generation automatically before your build scripts run.</p>
<p>This eliminates an entire category of signing-related build failures.</p>
<p><code>groups</code></p>
<p>Codemagic lets you organize secrets into named groups in the environment variables section of your team settings. Rather than declaring individual secrets inline, you reference groups. The groups used here are:</p>
<ul>
<li><p><code>staging_secrets</code>: contains <code>STAGING_BASE_URL</code> and <code>STAGING_API_KEY</code></p>
</li>
<li><p><code>production_secrets</code>: contains <code>PROD_BASE_URL</code> and <code>PROD_API_KEY</code></p>
</li>
<li><p><code>firebase_credentials</code>: contains <code>FIREBASE_TOKEN</code>, <code>FIREBASE_ANDROID_APP_ID</code>, <code>FIREBASE_GROUPS</code></p>
</li>
<li><p><code>sentry_credentials</code>: contains <code>SENTRY_AUTH_TOKEN</code>, <code>SENTRY_ORG</code>, <code>SENTRY_PROJECT</code></p>
</li>
</ul>
<p><strong>Environment detection with</strong> <code>$CM_ENV</code></p>
<p>Codemagic exposes a special file path via the <code>$CM_ENV</code> variable. Writing <code>KEY=VALUE</code> to this file makes that variable available to every subsequent script step in the same build. This is how the branch name gets translated into an environment label that the rest of the pipeline reads.</p>
<p><strong>Build differentiation</strong></p>
<p>Production builds use <code>--obfuscate</code> and <code>--split-debug-info=build/symbols</code>. Dev and staging builds skip both flags for faster compilation and readable local stack traces.</p>
<p><strong>Firebase distribution</strong></p>
<p>The Firebase CLI distributes dev and staging builds to testers. Because Codemagic's Linux machines come with Node.js available, you can install the Firebase CLI with <code>npm install -g firebase-tools</code> as a setup step if it is not already present, or invoke it via <code>npx</code>.</p>
<p><strong>Play Store submission</strong></p>
<p>Production app bundles go to the Play Store using Fastlane's <code>supply</code> command. The service account JSON is written to a temporary file from the environment variable and passed to Fastlane directly. Replace <code>com.your.package</code> with your actual application ID.</p>
<p><code>artifacts</code></p>
<p>The artifacts section tells Codemagic which files to preserve after the build completes. These files become downloadable from the Codemagic build dashboard. The debug symbols are captured here as well, which is useful for manual Sentry uploads if the automated step ever needs to be re-run.</p>
<h2 id="heading-ios-pipeline">iOS Pipeline</h2>
<p>iOS on Codemagic is where the platform's advantage becomes most visible. Apple code signing on a general-purpose runner requires a multi-step keychain dance involving <code>security</code> commands, certificate imports, and provisioning profile placement. Codemagic handles all of that automatically through its native signing integration.</p>
<pre><code class="language-yaml">  ios-pipeline:
    name: iOS Build &amp; Release
    max_build_duration: 90
    instance_type: mac_mini_m2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.your.bundle.id
      groups:
        - staging_secrets
        - production_secrets
        - app_store_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Install Fastlane dependencies
        script: |
          cd ios
          gem install bundler --user-install
          bundle install

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build iOS (dev — no signing)
        script: |
          if [ "$ENV" = "dev" ]; then
            flutter build ios --release --no-codesign
          fi

      - name: Build and ship to TestFlight (staging)
        script: |
          if [ "$ENV" = "staging" ]; then
            flutter build ipa --release \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane beta
          fi

      - name: Build and release to App Store (production)
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build ipa --release \
              --obfuscate \
              --split-debug-info=build/symbols \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane release
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/ios/ipa/*.ipa
      - build/symbols/**
      - /tmp/xcodebuild_logs/*.log

    publishing:
      app_store_connect:
        api_key: $APP_STORE_CONNECT_PRIVATE_KEY
        key_id: $APP_STORE_CONNECT_KEY_IDENTIFIER
        issuer_id: $APP_STORE_CONNECT_ISSUER_ID
        submit_to_testflight: true
        submit_to_app_store: false
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here's what is different from the Android workflow and why.</p>
<p><code>mac_mini_m2</code></p>
<p>iOS builds require Xcode, which means they need macOS. Codemagic provides Apple Silicon Mac Mini instances. These are meaningfully faster than Intel-based runners for Flutter and Xcode workloads, and Codemagic provisions them on demand without any infrastructure management on your side.</p>
<p><code>ios_signing</code></p>
<p>This is the section that replaces the entire keychain setup sequence. You upload your distribution certificate and provisioning profile once to Codemagic's code signing identities under your team settings. The <code>distribution_type: app_store</code> tells Codemagic to use App Store distribution signing, and <code>bundle_identifier</code> ties it to your specific app. Before your scripts run, Codemagic installs the certificate and profile automatically on the build machine.</p>
<p>No <code>security</code> commands, no keychain creation, no Base64 decoding. It's handled internally.</p>
<p><code>flutter build ipa</code></p>
<p>On iOS, the build output is an <code>.ipa</code> file rather than an <code>.aab</code>. Flutter's <code>flutter build ipa</code> command produces this directly when provided with an export options plist. The plist tells Xcode how to sign and package the output. Codemagic generates this file automatically based on your <code>ios_signing</code> configuration and places it at <code>/Users/builder/export_options.plist</code>.</p>
<p><strong>Fastlane lanes</strong></p>
<p>Codemagic installs Fastlane via Bundler in the <code>ios/</code> directory, then calls the appropriate lane based on the detected environment. The <code>beta</code> lane uploads to TestFlight, and the <code>release</code> lane submits to the App Store.</p>
<p><code>publishing.app_store_connect</code></p>
<p>Codemagic has a native App Store Connect publisher. Rather than scripting the upload manually, you declare your API credentials in the publishing block and Codemagic handles the submission. The <code>submit_to_testflight: true</code> flag means staging builds are automatically available to TestFlight testers after the build completes. For production, you would flip <code>submit_to_app_store</code> to <code>true</code> instead.</p>
<p><strong>Xcode logs as artifacts</strong></p>
<p>The line <code>/tmp/xcodebuild_logs/*.log</code> captures raw Xcode build logs as downloadable artifacts. When an iOS build fails and the error message in the Codemagic dashboard is not specific enough, these logs are where you find the real cause.</p>
<h2 id="heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</h2>
<p>All secrets are configured in Codemagic under Teams → Environment variables. Group them logically so they can be referenced cleanly in the YAML.</p>
<p><strong>staging_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>production_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>firebase_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code></td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names</td>
</tr>
</tbody></table>
<p><strong>app_store_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>APP_STORE_CONNECT_PRIVATE_KEY</code></td>
<td>Contents of the <code>.p8</code> key file from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_KEY_IDENTIFIER</code></td>
<td>Key ID from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_ISSUER_ID</code></td>
<td>Issuer ID from App Store Connect</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>sentry_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p>For Android code signing, upload your keystore directly under Teams → Code signing identities → Android keystores rather than storing it as an environment variable.</p>
<p>For iOS, upload your distribution certificate and provisioning profile under Teams → Code signing identities → iOS certificates.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With the full <code>codemagic.yaml</code> in place, here is the complete picture of what happens across a typical release cycle.</p>
<p>A developer finishes a feature and raises a PR into <code>develop</code>. Codemagic detects the pull request event and triggers the <code>pr-quality-gate</code> workflow on a Linux machine. The quality checks script runs formatting, analysis, tests, and coverage threshold check. If anything fails, Codemagic marks the build as failed, sends the team an email, and the PR cannot be considered ready. The developer pushes a fix, Codemagic runs again, and only when everything passes does the PR move forward.</p>
<p>Once the PR merges into <code>develop</code>, both the <code>android-pipeline</code> and <code>ios-pipeline</code> trigger simultaneously. Each detects <code>develop</code> as the source branch, maps it to the dev environment, injects placeholder config, builds an unsigned release artifact, and ships it to Firebase App Distribution. Testers have an installable build within minutes of the merge completing.</p>
<p>When <code>develop</code> is merged into <code>staging</code>, the same two platform pipelines fire again. This time real secrets are injected , the staging API URL, the staging encryption key. Android builds are signed with the keystore Codemagic manages automatically. iOS builds go through Fastlane's <code>beta</code> lane to TestFlight. The Codemagic App Store Connect publisher handles the TestFlight upload natively. QA now has a properly signed, properly configured staging build to test against.</p>
<p>When <code>staging</code> is promoted to <code>production</code>, the pipelines enter release mode. Production secrets are injected. Android builds are obfuscated with debug symbols split into <code>build/symbols</code>. iOS builds go through <code>flutter build ipa</code> with obfuscation enabled. Both platform pipelines call <code>upload_symbols.sh</code> with the current commit SHA, linking the Sentry release to the exact code that shipped. The Android bundle goes to the Play Store via Fastlane. The iOS IPA is submitted to App Store Connect via Codemagic's native publisher. The team receives a success notification.</p>
<p>That's the full cycle. No terminal, no manual step, no shared Slack message saying "I think I deployed staging."</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pipeline we just built covers the full release lifecycle: automated quality enforcement, environment-aware config injection, platform-specific signed builds, tester distribution, crash observability, and store submission , all from a single <code>codemagic.yaml</code> file.</p>
<p>What Codemagic brings to this setup is a tighter integration with the mobile ecosystem specifically. The keystore management, native App Store Connect publisher, pre-installed Flutter toolchain, and Apple Silicon Mac instances aren't add-ons you configure , they're part of the platform's core. This translates into fewer steps to maintain, fewer failure surfaces, and a pipeline that's easier to reason about when something does go wrong.</p>
<p>The scripts in your <code>scripts/</code> folder remain completely platform-agnostic. If your team ever needs to move pipelines, those scripts move with you unchanged. The YAML changes, but the logic doesn't.</p>
<p>What you have at the end of this setup is a release process your team can trust: one where "did it deploy?" is answered by a notification, not a question in Slack.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Factory and Abstract Factory Design Patterns Work in Flutter ]]>
                </title>
                <description>
                    <![CDATA[ In software development, particularly object-oriented programming and design, object creation is a common task. And how you manage this process can impact your app's flexibility, scalability, and maintainability. Creational design patterns govern how... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-factory-and-abstract-factory-design-patterns-work-in-flutter/</link>
                <guid isPermaLink="false">6978f477116625d0304ed264</guid>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Factory Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile apps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OOPS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Object Oriented Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design principles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ object oriented design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Abstract Factory Patterns ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 27 Jan 2026 17:23:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769533734673/8b5ad88a-13d2-4fec-969b-55fd854df5c1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In software development, particularly object-oriented programming and design, object creation is a common task. And how you manage this process can impact your app's flexibility, scalability, and maintainability.</p>
<p>Creational design patterns govern how classes and objects are created in a systematic and scalable way. They provide blueprints for creating objects so you don't repeat code. They also keep your system consistent and makes your app easy to extend.</p>
<p>There are five major Creational Design patterns:</p>
<ol>
<li><p><strong>Singleton:</strong> Ensures a class has only one instance and provides a global point of access to it.</p>
</li>
<li><p><strong>Factory Method</strong>: Provides an interface for creating objects but lets subclasses decide which class to instantiate.</p>
</li>
<li><p><strong>Abstract Factory</strong>: Creates families of related objects without specifying their concrete classes.</p>
</li>
<li><p><strong>Builder</strong>: Allows you to construct complex objects step by step, separating construction from representation.</p>
</li>
<li><p><strong>Prototype</strong>: Creates new objects by cloning existing ones, rather than creating from scratch.</p>
</li>
</ol>
<p>Each of these patterns solves specific problems around object creation, depending on the complexity and scale of your application.</p>
<p>In this tutorial, I'll explain what Creational Design Patterns are and how they work. We'll focus on two primary patterns: the Factory and Abstract Factory patterns.</p>
<p>Many people mix these two up, so here we'll explore:</p>
<ol>
<li><p>How each pattern works</p>
</li>
<li><p>Practical examples in Flutter</p>
</li>
<li><p>Applications, best practices, and usage</p>
</li>
</ol>
<p>By the end, you'll understand when to use Factory, when to switch to Abstract Factory, and how to structure your Flutter apps for scalability and maintainability.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-how-the-factory-pattern-works-in-flutter">How the Factory Pattern Works in Flutter</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-step-1-define-the-product-and-abstract-creator">Step 1: Define the Product and Abstract Creator</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-implement-concrete-products">Step 2: Implement Concrete Products</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-create-the-factory">Step 3: Create the Factory</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-use-the-factory">Step 4: Use the Factory</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-factory-pattern-for-security-checks">Factory Pattern for Security Checks</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-the-abstract-factory-pattern-works-in-flutter">How the Abstract Factory Pattern Works in Flutter</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-step-1-define-abstract-product-interfaces">Step 1: Define Abstract Product Interfaces</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-implement-platform-specific-products">Step 2: Implement Platform-Specific Products</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-define-the-abstract-factory-interface">Step 3: Define the Abstract Factory Interface</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-implement-platform-specific-factories">Step 4: Implement Platform Specific Factories</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-client-code-using-abstract-factory">Step 5: Client Code Using Abstract Factory</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have:</p>
<ul>
<li><p>a basic understanding of the Dart programming language</p>
</li>
<li><p>familiarity with Object-Oriented Programming (OOP) concepts (particularly classes, inheritance, and abstract classes)</p>
</li>
<li><p>basic knowledge of Flutter development (helpful but not required)</p>
</li>
<li><p>an understanding of interfaces and polymorphism</p>
</li>
<li><p>and experience creating and instantiating classes in Dart.</p>
</li>
</ul>
<h2 id="heading-how-the-factory-pattern-works-in-flutter">How the Factory Pattern Works in Flutter</h2>
<p>You'll typically use the Factory Pattern when you want to manage data sets that might be related, but only for a single type of object.</p>
<p>Let's say you want to manage themes for Android and iOS. Using the Factory Pattern allows you to encapsulate object creation and keep your app modular. We'll build this step by step so you can see how the pattern works.</p>
<h3 id="heading-step-1-define-the-product-and-abstract-creator">Step 1: Define the Product and Abstract Creator</h3>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppTheme</span> </span>{
  <span class="hljs-built_in">String?</span> data;
  AppTheme({<span class="hljs-keyword">this</span>.data});
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  Future&lt;AppTheme&gt; getApplicationTheme();
}
</code></pre>
<p>Here, <code>AppTheme</code> is a simple data class that holds theme information. This represents the product our factory will create. <code>ApplicationThemeData</code> serves as an abstract base class. This abstraction is crucial because it defines a contract that all concrete theme implementations must follow.</p>
<p>By requiring a <code>getApplicationTheme()</code> method, we ensure consistency across different platforms.</p>
<h3 id="heading-step-2-implement-concrete-products">Step 2: Implement Concrete Products</h3>
<p>Now we create platform-specific implementations that provide actual theme data.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidAppTheme</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;AppTheme&gt; getApplicationTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> AppTheme(data: <span class="hljs-string">"Here is android theme"</span>);
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSThemeData</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;AppTheme&gt; getApplicationTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> AppTheme(data: <span class="hljs-string">"This is IOS theme data"</span>);
  }
}
</code></pre>
<p>The concrete implementations, <code>AndroidAppTheme</code> and <code>IOSThemeData</code>, extend the abstract class and provide platform-specific theme data. Each returns an <code>AppTheme</code> object with content tailored to its respective platform.</p>
<h3 id="heading-step-3-create-the-factory">Step 3: Create the Factory</h3>
<p>The factory encapsulates the object creation logic, so client code doesn't need to know which specific theme class it's working with.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ThemeFactory</span> </span>{
  ThemeFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.theme});
  ApplicationThemeData theme;

  loadTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> theme.getApplicationTheme();
  }
}
</code></pre>
<p><code>ThemeFactory</code> acts as the factory itself. It accepts any <code>ApplicationThemeData</code> implementation and provides a unified <code>loadTheme()</code> method. This encapsulates the object creation logic cleanly.</p>
<h3 id="heading-step-4-use-the-factory">Step 4: Use the Factory</h3>
<p>Finally, we use the factory in our application code.</p>
<pre><code class="lang-dart">ThemeFactory(
  theme: Platform.isAndroid ? AndroidAppTheme() : IOSThemeData()
).loadTheme();
</code></pre>
<p>Here, you choose a theme (Android or iOS) and get the corresponding <code>AppTheme</code>. This approach is simple and effective when you only care about one functionality, like loading a theme.</p>
<p>The beauty of this pattern is that the client code remains clean and doesn't need to change if you add new platforms later.</p>
<h2 id="heading-factory-pattern-for-security-checks">Factory Pattern for Security Checks</h2>
<p>Another excellent use case for the Factory Pattern is when implementing security checks during your application bootstrap.</p>
<p>For instance, Android and iOS require different logic for internal security. Android might check for developer mode or rooted devices, while iOS checks for jailbroken devices. This scenario is a perfect example of when to apply the Factory Pattern, as it allows you to encapsulate platform-specific security logic cleanly and maintainably. Let's implement this step by step.</p>
<h3 id="heading-step-1-define-security-check-result-and-abstract-checker">Step 1: Define Security Check Result and Abstract Checker</h3>
<p>First, we need a standardized way to communicate security check outcomes and a contract for performing security checks.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Base security check result class</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityCheckResult</span> </span>{
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">bool</span> isSecure;
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> message;

  SecurityCheckResult({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.isSecure, <span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.message});
}

<span class="hljs-comment">// Abstract security checker</span>
<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityChecker</span> </span>{
  Future&lt;SecurityCheckResult&gt; performSecurityCheck();
}
</code></pre>
<p>The <code>SecurityCheckResult</code> class provides a standardized way to communicate security check outcomes across platforms.</p>
<p>It contains a boolean flag indicating security status and a descriptive message for the user. The abstract <code>SecurityChecker</code> class defines the contract that all platform-specific security implementations must follow.</p>
<p>This ensures that, regardless of the platform, we can always call <code>performSecurityCheck()</code> and receive a consistent result type.</p>
<h3 id="heading-step-2-implement-platform-specific-security-checkers">Step 2: Implement Platform-Specific Security Checkers</h3>
<p>Now we create the actual security checking implementations for each platform.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Android-specific security implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidSecurityChecker</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">SecurityChecker</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;SecurityCheckResult&gt; performSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-built_in">bool</span> isRooted = <span class="hljs-keyword">await</span> checkIfDeviceIsRooted();
    <span class="hljs-keyword">if</span> (isRooted) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Device is rooted. App cannot run on rooted devices."</span>
      );
    }

    <span class="hljs-built_in">bool</span> isDeveloperMode = <span class="hljs-keyword">await</span> checkDeveloperMode();
    <span class="hljs-keyword">if</span> (isDeveloperMode) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Developer mode is enabled. Please disable it to continue."</span>
      );
    }

    <span class="hljs-keyword">return</span> SecurityCheckResult(
      isSecure: <span class="hljs-keyword">true</span>,
      message: <span class="hljs-string">"Device security check passed."</span>
    );
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkIfDeviceIsRooted() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; 
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkDeveloperMode() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; <span class="hljs-comment">// Placeholder</span>
  }
}

<span class="hljs-comment">// iOS-specific security implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSSecurityChecker</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">SecurityChecker</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;SecurityCheckResult&gt; performSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-built_in">bool</span> isJailbroken = <span class="hljs-keyword">await</span> checkIfDeviceIsJailbroken();

    <span class="hljs-keyword">if</span> (isJailbroken) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Device is jailbroken. App cannot run on jailbroken devices."</span>
      );
    }

    <span class="hljs-keyword">return</span> SecurityCheckResult(
      isSecure: <span class="hljs-keyword">true</span>,
      message: <span class="hljs-string">"Device security check passed."</span>
    );
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkIfDeviceIsJailbroken() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; 
  }
}
</code></pre>
<p>The Android implementation focuses on detecting rooted devices and developer mode, which are common security concerns on Android.</p>
<p>A rooted device has elevated permissions that could allow malicious apps to access sensitive data, while developer mode can expose debugging interfaces.</p>
<p>The iOS implementation checks for jailbroken devices, which is the iOS equivalent of rooting. Jailbroken devices bypass Apple's security restrictions and can pose similar security risks.</p>
<h3 id="heading-step-3-create-the-security-factory">Step 3: Create the Security Factory</h3>
<p>The factory wraps the chosen security checker and provides a clean interface for running checks.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Security Factory</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityCheckFactory</span> </span>{
  SecurityCheckFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.checker});
  SecurityChecker checker;

  Future&lt;SecurityCheckResult&gt; runSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> checker.performSecurityCheck();
  }
}
</code></pre>
<p>The <code>SecurityCheckFactory</code> provides a simple interface that accepts any <code>SecurityChecker</code> implementation. This means your app initialization code doesn't need to know about platform-specific security details – it just calls <code>runSecurityCheck()</code> and handles the result.</p>
<h3 id="heading-step-4-use-the-security-factory-in-app-bootstrap">Step 4: Use the Security Factory in App Bootstrap</h3>
<p>Finally, we integrate the security factory into our app's initialization process.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// In your app's bootstrap/initialization</span>
Future&lt;<span class="hljs-keyword">void</span>&gt; initializeApp() <span class="hljs-keyword">async</span> {
  <span class="hljs-keyword">final</span> securityFactory = SecurityCheckFactory(
    checker: Platform.isAndroid 
      ? AndroidSecurityChecker() 
      : IOSSecurityChecker()
  );

  <span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> securityFactory.runSecurityCheck();

  <span class="hljs-keyword">if</span> (!result.isSecure) {
    <span class="hljs-comment">// Show error dialog and prevent app from continuing</span>
    showSecurityErrorDialog(result.message);
    <span class="hljs-keyword">return</span>;
  }

  <span class="hljs-comment">// Continue with normal app initialization</span>
  runApp(MyApp());
}
</code></pre>
<p>This usage example demonstrates how the Factory Pattern makes your app initialization code clean and maintainable.</p>
<p>The platform detection happens in one place, the factory handles the creation of the appropriate checker, and your code simply deals with the standardized result.</p>
<p><strong>Key takeaway:</strong> Factory is great when you need one type of object, but you want to abstract away the creation logic.</p>
<h2 id="heading-how-the-abstract-factory-pattern-works-in-flutter">How the Abstract Factory Pattern Works in Flutter</h2>
<p>The Abstract Factory Pattern comes into play when you have more than two data sets for comparison, and each set includes multiple functionalities.</p>
<p>For example, imagine you now want to manage themes, widgets, and architecture for Android, iOS, and Linux. Managing this with just a Factory becomes messy, so Abstract Factory provides a structured way to handle multiple related objects for different platforms.</p>
<p>So let's see how you can handle this using the abstract factory method.</p>
<h3 id="heading-step-1-define-abstract-product-interfaces">Step 1: Define Abstract Product Interfaces</h3>
<p>Before we dive into this implementation, it's important to understand what abstract product interfaces are. An abstract product interface is essentially a contract that defines what methods a product must implement, without specifying how they're implemented.</p>
<p>Think of it as a blueprint that ensures all related products share a common structure. In our case, we're defining three core functionalities that every platform must provide:</p>
<ol>
<li><p>Theme management</p>
</li>
<li><p>Widget handling</p>
</li>
<li><p>Architecture configuration.</p>
</li>
</ol>
<p>By creating these abstract interfaces first, we establish a consistent API that all platform-specific implementations will follow.</p>
<pre><code class="lang-dart"><span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ThemeManager</span> </span>{
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme();
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WidgetHandler</span> </span>{
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget();
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture();
}
</code></pre>
<p>Here, we’re defining three base functionalities that every platform will implement: theme, widgets, and architecture.</p>
<p>Each interface declares a single method that returns platform-specific information.</p>
<p>The <code>ThemeManager</code> retrieves theme data, <code>WidgetHandler</code> determines widget compatibility, and <code>ArchitechtureHandler</code> provides architecture details.</p>
<h3 id="heading-step-2-implement-platform-specific-products">Step 2: Implement Platform-Specific Products</h3>
<p>Now that we have our abstract interfaces defined, we need to create concrete implementations for each platform. This step is where we provide the actual, platform-specific behavior for each product type. Think of this as filling in the blueprint with real details.</p>
<p>While the abstract interfaces told us what methods we need, these concrete classes tell us how those methods behave on each specific platform. Each platform (Android, iOS, Linux) will have its own unique implementation of themes, widgets, and architecture.</p>
<h4 id="heading-android">Android:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Android Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Android Architecture"</span>;
  }
}
</code></pre>
<p>For Android, we're creating three specific product classes. The <code>AndroidThemeManager</code> returns Material Design theme data, the <code>AndroidWidgetHandler</code> returns true to indicate that Android supports home screen widgets, and the <code>AndroidArchitechtureHandler</code> provides information about Android's architecture (which could include details about ARM, x86, or other processor architectures).</p>
<h4 id="heading-ios">iOS:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"IOS Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"iOS Architecture"</span>;
  }
}
</code></pre>
<p>The iOS implementations follow the same structure but provide iOS-specific values. Notice that <code>IOSWidgetHandler</code> returns false, this could represent a scenario where certain widget features aren't available or behave differently on iOS compared to Android.</p>
<h4 id="heading-linux">Linux:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Linux Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Linux Architecture"</span>;
  }
}
</code></pre>
<p>Similarly, Linux gets its own set of implementations, providing Linux-specific theme data and architecture information.</p>
<h3 id="heading-step-3-define-the-abstract-factory-interface">Step 3: Define the Abstract Factory Interface</h3>
<p>With our product classes ready, we now need to create the factory that will produce them.</p>
<p>The abstract factory interface is the master blueprint that declares which products our factory must be able to create. This interface doesn't create anything itself, it simply declares that any concrete factory must provide methods to create all three product types (theme, widget, and architecture handlers). This ensures that, regardless of which platform factory we use, we can always access all three functionalities.</p>
<pre><code class="lang-dart"><span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppFactory</span> </span>{
  ThemeManager themeManager();
  WidgetHandler widgetManager();
  ArchitechtureHandler architechtureHandler();
}
</code></pre>
<p>Here, we define a factory blueprint. Any platform specific factory will have to implement all three functionalities. This guarantees consistency: every platform will have all three capabilities available.</p>
<h3 id="heading-step-4-implement-platform-specific-factories">Step 4: Implement Platform Specific Factories</h3>
<p>This is where everything comes together. We're now creating the actual factories that will produce the platform-specific products we defined earlier. Each factory is responsible for creating all the related products for its platform. The key advantage here is encapsulation: the factory knows how to create all the related objects for a platform, and it ensures they're compatible with each other. For example, <code>AndroidFactory</code> creates Android-specific theme managers, widget handlers, and architecture handlers that all work together seamlessly.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; AndroidThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; AndroidWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; AndroidArchitechtureHandler();
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; IOSThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; IOSWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; IOSArchitechtureHandler();
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; LinuxThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; LinuxWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; LinuxArchitechtureHandler();
}
</code></pre>
<p>Each concrete factory (AndroidFactory, IOSFactory, LinuxFactory) implements all three methods from the <code>AppFactory</code> interface. When you call <code>themeManager()</code> on <code>AndroidFactory</code>, you get an <code>AndroidThemeManager</code>. When you call it on <code>IOSFactory</code>, you get an <code>IOSThemeManager</code>. The same pattern applies to all products.</p>
<h3 id="heading-step-5-client-code-using-abstract-factory">Step 5: Client Code Using Abstract Factory</h3>
<p>Finally, we create the client code that uses our abstract factory. This is the layer that your application will actually interact with. The beauty of this pattern is that the client code doesn't need to know anything about the specific platform implementations, it just works with the abstract factory interface.</p>
<p>The <code>AppBaseFactory</code> class accepts any factory that implements <code>AppFactory</code> and provides a simple method to initialize all platform settings. The <code>CheckDevice</code> class determines which factory to use based on the current platform, completely abstracting this decision away from the rest of your application.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppBaseFactory</span> </span>{
  AppBaseFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.<span class="hljs-keyword">factory</span>});
  AppFactory <span class="hljs-keyword">factory</span>;

  getAppSettings() {
    <span class="hljs-keyword">factory</span>
      ..architechtureHandler()
      ..themeManager()
      ..widgetManager();
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CheckDevice</span> </span>{
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">get</span>() {
    <span class="hljs-keyword">if</span> (Platform.isAndroid) <span class="hljs-keyword">return</span> AndroidFactory();
    <span class="hljs-keyword">if</span> (Platform.isIOS) <span class="hljs-keyword">return</span> IOSFactory();
    <span class="hljs-keyword">if</span> (Platform.isLinux) <span class="hljs-keyword">return</span> LinuxFactory();
    <span class="hljs-keyword">throw</span> UnsupportedError(<span class="hljs-string">"Platform not supported"</span>);
  }
}

<span class="hljs-comment">// Usage</span>
AppBaseFactory(<span class="hljs-keyword">factory</span>: CheckDevice.<span class="hljs-keyword">get</span>()).getAppSettings();
</code></pre>
<p>Here's what's happening in this code:</p>
<p>The <code>AppBaseFactory</code> class acts as a wrapper around any <code>AppFactory</code> implementation. It provides a convenient <code>getAppSettings()</code> method that initializes all three components (architecture handler, theme manager, and widget manager) using Dart's cascade notation.</p>
<p>The <code>CheckDevice</code> class contains the platform detection logic. Its static <code>get()</code> method checks the current platform and returns the appropriate factory. This centralizes all platform detection in one place. When you call <code>AppBaseFactory(factory: CheckDevice.get()).getAppSettings()</code>, the code automatically detects your platform, creates the right factory, and initializes all platform-specific components, all without the calling code needing to know any platform-specific details.</p>
<p>Each platform factory produces all related products. The client only interacts with <code>AppBaseFactory</code>, remaining unaware of the internal implementation. This ensures your code is scalable, maintainable, and consistent.</p>
<h2 id="heading-real-world-application-payment-provider-management">Real-World Application: Payment Provider Management</h2>
<p>Another good use case for abstract factory is when you need to switch between multiple payment providers in your application and you only want to expose the necessary functionality to the client (presentation layer).</p>
<p>The abstract factory design pattern properly helps you manage this scenario in terms of concrete implementation, encapsulation, clean code, separation of concerns, and proper code structure and management. For example, you might support Stripe, PayPal, and Flutterwave in your application.</p>
<p>Each provider requires different initialization, transaction processing, and webhook handling. By using the Abstract Factory pattern, you can create a consistent interface for all payment operations while keeping provider-specific details encapsulated within their respective factory implementations.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You should now feel more comfortable deciding when to use the Factory design pattern vs the Abstract Factory design pattern.</p>
<p>Understanding the factory and abstract factory patterns and their usages properly will help with object creation based on the particular use case you are trying to implement.</p>
<p>The Factory Pattern is ideal when you need one product and want to encapsulate creation logic while the Abstract Factory Pattern works well when you have multiple related products across platforms, need consistency, and want scalability. Using these patterns will help you write clean, maintainable, and scalable Flutter apps.</p>
<p>They give you a systematic approach to object creation and prevent messy, hard-to-maintain code as your app grows.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Singleton Design Pattern in Flutter: Lazy, Eager, and Factory Variations ]]>
                </title>
                <description>
                    <![CDATA[ In software engineering, sometimes you need only one instance of a class across your entire application. Creating multiple instances in such cases can lead to inconsistent behavior, wasted memory, or resource conflicts. The Singleton Design Pattern i... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-the-singleton-design-pattern-in-flutter-lazy-eager-and-factory-variations/</link>
                <guid isPermaLink="false">69740b7bc3e68b8de44a179f</guid>
                
                    <category>
                        <![CDATA[ Singleton Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Object Oriented Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ood ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ flutter development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Factory Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 23 Jan 2026 23:59:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769212761076/11d41d2a-8efa-4ddb-9ee2-218f5be00d9f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In software engineering, sometimes you need only one instance of a class across your entire application. Creating multiple instances in such cases can lead to inconsistent behavior, wasted memory, or resource conflicts.</p>
<p>The Singleton Design Pattern is a creational design pattern that solves this problem by ensuring that a class has exactly one instance and provides a global point of access to it.</p>
<p>This pattern is widely used in mobile apps, backend systems, and Flutter applications for managing shared resources such as:</p>
<ul>
<li><p>Database connections</p>
</li>
<li><p>API clients</p>
</li>
<li><p>Logging services</p>
</li>
<li><p>Application configuration</p>
</li>
<li><p>Security checks during app bootstrap</p>
</li>
</ul>
<p>In this article, we'll explore what the Singleton pattern is, how to implement it in Flutter/Dart, its variations (eager, lazy, and factory), and physical examples. By the end, you'll understand the proper way to use this pattern effectively and avoid common pitfalls.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-the-singleton-pattern">What is the Singleton Pattern?</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-when-to-use-the-singleton-pattern">When to Use the Singleton Pattern</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-create-a-singleton-class">How to Create a Singleton Class</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-eager-singleton">Eager Singleton</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-lazy-singleton">Lazy Singleton</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-choosing-between-eager-and-lazy">Choosing Between Eager and Lazy</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-factory-constructors-in-the-singleton-pattern">Factory Constructors in the Singleton Pattern</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-are-factory-constructors">What Are Factory Constructors?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-implementing-singleton-with-factory-constructor">Implementing Singleton with Factory Constructor</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-when-not-to-use-a-singleton">When Not to Use a Singleton</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-singletons-can-be-problematic">Why Singletons Can Be Problematic</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-scenarios-where-you-should-avoid-singletons">Scenarios Where You Should Avoid Singletons</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-general-guidelines">General Guidelines</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have:</p>
<ol>
<li><p>Basic understanding of the Dart programming language</p>
</li>
<li><p>Familiarity with Object-Oriented Programming (OOP) concepts, particularly classes and constructors</p>
</li>
<li><p>Basic knowledge of Flutter development (helpful but not required)</p>
</li>
<li><p>Understanding of static variables and methods in Dart</p>
</li>
<li><p>Familiarity with the concept of class instantiation</p>
</li>
</ol>
<h2 id="heading-what-is-the-singleton-pattern">What is the Singleton Pattern?</h2>
<p>The Singleton pattern is a creational design pattern that ensures a class has only one instance and that there is a global point of access to the instance.</p>
<p>Again, this is especially powerful when managing shared resources across an application.</p>
<h3 id="heading-when-to-use-the-singleton-pattern">When to Use the Singleton Pattern</h3>
<p>You should use a Singleton when you are designing parts of your system that must exist once, such as:</p>
<ol>
<li><p>Global app state (user session, auth token, app config)</p>
</li>
<li><p>Shared services (logger, API client, database connection)</p>
</li>
<li><p>Resource heavy logic (encryption handlers, ML models, cache manager)</p>
</li>
<li><p>Application boot security (run platform-specific root/jailbreak checks)</p>
</li>
</ol>
<p>For example, in a Flutter app, Android may check developer mode or root status, while iOS checks jailbroken device state. A Singleton security class is a perfect way to enforce that these checks run once globally during app startup.</p>
<h2 id="heading-how-to-create-a-singleton-class">How to Create a Singleton Class</h2>
<p>We have two major ways of creating a singleton class:</p>
<ol>
<li><p>Eager Instantiation</p>
</li>
<li><p>Lazy Instantiation</p>
</li>
</ol>
<h3 id="heading-eager-singleton">Eager Singleton</h3>
<p>This is where the Singleton is created at load time, whether it's used or not.</p>
<p>In this case, the instance of the singleton class as well as any initialization logic runs at load time, regardless of when this class is actually needed or used. Here's how it works:</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">EagerSingleton</span> </span>{
  EagerSingleton._internal();
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> EagerSingleton _instance = EagerSingleton._internal();

  <span class="hljs-keyword">static</span> EagerSingleton <span class="hljs-keyword">get</span> instance =&gt; _instance;

  <span class="hljs-keyword">void</span> sayHello() =&gt; <span class="hljs-built_in">print</span>(<span class="hljs-string">"Hello from Eager Singleton"</span>);
}

<span class="hljs-comment">//usage</span>
<span class="hljs-keyword">void</span> main() {
  <span class="hljs-comment">// Accessing the singleton globally</span>
  EagerSingleton.instance.sayHello();
}
</code></pre>
<h4 id="heading-how-the-eager-singleton-works">How the Eager Singleton Works</h4>
<p>Let's break down what's happening in this implementation:</p>
<p>First, <code>EagerSingleton._internal()</code> is a private named constructor (notice the underscore prefix). This prevents external code from creating new instances using <code>EagerSingleton()</code>. The only way to get an instance is through the controlled mechanism we're about to define.</p>
<p>Next, <code>static final EagerSingleton _instance = EagerSingleton._internal();</code> is the key line. This creates the single instance immediately when the class is first loaded into memory. Because it's <code>static final</code>, it belongs to the class itself (not any particular instance) and can only be assigned once. The instance is created right here, at declaration time.</p>
<p>The <code>static EagerSingleton get instance =&gt; _instance;</code> getter provides global access to that single instance. Whenever you call <code>EagerSingleton.instance</code> anywhere in your code, you're getting the exact same object that was created when the class loaded.</p>
<p>Finally, <code>sayHello()</code> is just a regular method to demonstrate that the singleton works. You could replace this with any business logic your singleton needs to perform.</p>
<p>When you run the code in <code>main()</code>, the class loads, the instance is created immediately, and <code>EagerSingleton.instance.sayHello()</code> accesses that pre-created instance to call the method.</p>
<h4 id="heading-pros">Pros:</h4>
<ol>
<li><p>This is simple and thread safe, meaning it's not affected by concurrency, especially when your app runs on multithreads.</p>
</li>
<li><p>It's ideal if the instance is lightweight and may be accessed frequently.</p>
</li>
</ol>
<h4 id="heading-cons">Cons:</h4>
<ol>
<li>If this instance is never used through the runtime, it results in wasted memory and could impact application performance.</li>
</ol>
<h3 id="heading-lazy-singleton">Lazy Singleton</h3>
<p>In this case, the singleton instance is only created when the class is called or needed in runtime. Here, a trigger needs to happen before the instance is created. Let's see an example:</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LazySingleton</span> </span>{
  LazySingleton._internal(); 
  <span class="hljs-keyword">static</span> LazySingleton? _instance;

  <span class="hljs-keyword">static</span> LazySingleton <span class="hljs-keyword">get</span> instance {
    _instance ??= LazySingleton._internal();
    <span class="hljs-keyword">return</span> _instance!;
  }

  <span class="hljs-keyword">void</span> sayHello() =&gt; <span class="hljs-built_in">print</span>(<span class="hljs-string">"Hello from LazySingleton"</span>);
}

<span class="hljs-comment">//usage </span>
<span class="hljs-keyword">void</span> main() {
  <span class="hljs-comment">// Accessing the singleton globally</span>
  LazySingleton.instance.sayHello();
}
</code></pre>
<h4 id="heading-how-the-lazy-singleton-works">How the Lazy Singleton Works</h4>
<p>The lazy implementation differs from eager in one crucial way: timing.</p>
<p>Again, <code>LazySingleton._internal()</code> is a private constructor that prevents external instantiation.</p>
<p>But notice that <code>static LazySingleton? _instance;</code> is declared as nullable and not initialized. Unlike the eager version, no instance is created at load time. The variable simply exists as <code>null</code> until it's needed.</p>
<p>The magic happens in the getter: <code>_instance ??= LazySingleton._internal();</code> uses Dart's null-aware assignment operator. This line says "if <code>_instance</code> is null, create a new instance and assign it. Otherwise, keep the existing one." This is the lazy initialization: the instance is only created the first time someone accesses it.</p>
<p>The first time you call <code>LazySingleton.instance</code>, <code>_instance</code> is null, so a new instance is created. Every subsequent call finds that <code>_instance</code> already exists, so it just returns that same instance.</p>
<p>The <code>return _instance!;</code> uses the null assertion operator because we know <code>_instance</code> will never be null at this point (we just ensured it's not null in the previous line).</p>
<p>This approach saves memory because if you never call <code>LazySingleton.instance</code> in your app, the instance never gets created.</p>
<h4 id="heading-pros-1">Pros:</h4>
<ol>
<li><p>Saves application memory, as it only creates what is needed in runtime.</p>
</li>
<li><p>Avoids memory leaks.</p>
</li>
<li><p>Is ideal for resource heavy objects while considering application performance.</p>
</li>
</ol>
<h4 id="heading-cons-1">Cons:</h4>
<ol>
<li>Could be difficult to manage in multithreaded environments, as you have to ensure thread safety while following this pattern.</li>
</ol>
<h3 id="heading-choosing-between-eager-and-lazy">Choosing Between Eager and Lazy</h3>
<p>Now that we've broken down these two major types of singleton instantiation, it's worthy of note that you'll need to be intentional while deciding whether to create a singleton the eager or lazy way. Your use case/context should help you determine what singleton pattern you need to apply during object creation.</p>
<p>As an engineer, you need to ask yourself these questions when using a singleton for object creation:</p>
<ol>
<li><p>Do I need this class instantiated when the app loads?</p>
</li>
<li><p>Based on the user journey, will this class always be needed during every session?</p>
</li>
<li><p>Can a user journey be completed without needing to call any logic in this class?</p>
</li>
</ol>
<p>These three questions will determine what pattern (eager or lazy) you should use to fulfill best practices while maintaining scalability and high performance in your application.</p>
<h2 id="heading-factory-constructors-in-the-singleton-pattern">Factory Constructors in the Singleton Pattern</h2>
<p>Applying factory constructors in the Singleton pattern can be powerful if you use them properly. But first, let's understand what factory constructors are.</p>
<h3 id="heading-what-are-factory-constructors">What Are Factory Constructors?</h3>
<p>A factory constructor in Dart is a special type of constructor that doesn't always create a new instance of its class. Unlike regular constructors that must return a new instance, factory constructors can:</p>
<ol>
<li><p>Return an existing instance (perfect for singletons)</p>
</li>
<li><p>Return a subclass instance</p>
</li>
<li><p>Apply logic before deciding what to return</p>
</li>
<li><p>Perform validation or initialization before returning an object</p>
</li>
</ol>
<p>The <code>factory</code> keyword tells Dart that this constructor has the flexibility to return any instance of the class (or its subtypes), not necessarily a fresh one.</p>
<h3 id="heading-implementing-singleton-with-factory-constructor">Implementing Singleton with Factory Constructor</h3>
<p>This allows you to apply initialization logic while your class instance is being created before returning the instance.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">FactoryLazySingleton</span> </span>{
  FactoryLazySingleton._internal();
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> FactoryLazySingleton _instance = FactoryLazySingleton._internal();

  <span class="hljs-keyword">static</span> FactoryLazySingleton <span class="hljs-keyword">get</span> instance =&gt; _instance;

  <span class="hljs-keyword">factory</span> FactoryLazySingleton() {
    <span class="hljs-comment">// Your logic runs here</span>
    <span class="hljs-built_in">print</span>(<span class="hljs-string">"Factory constructor called"</span>);
    <span class="hljs-keyword">return</span> _instance;
  }
}
</code></pre>
<h4 id="heading-how-the-factory-constructor-singleton-works">How the Factory Constructor Singleton Works</h4>
<p>This implementation combines aspects of both eager and lazy patterns with additional control.</p>
<p>The <code>FactoryLazySingleton._internal()</code> private constructor and <code>static final _instance</code> create an eager singleton. The instance is created immediately when the class loads.</p>
<p>The <code>static get instance</code> provides the traditional singleton access pattern we've seen before.</p>
<p>But the interesting part is the <code>factory FactoryLazySingleton()</code> constructor. This is a public constructor that looks like a normal constructor call, but behaves differently. When you call <code>FactoryLazySingleton()</code>, instead of creating a new instance, it runs whatever logic you've placed inside (in this case, a print statement), then returns the existing <code>_instance</code>.</p>
<p>This pattern is powerful because:</p>
<ol>
<li><p>You can log when someone tries to create an instance</p>
</li>
<li><p>You can validate conditions before returning the instance</p>
</li>
<li><p>You can apply configuration based on parameters passed to the factory</p>
</li>
<li><p>You can choose to return different singleton instances based on conditions</p>
</li>
</ol>
<p>For example, you might have different configuration singletons for development vs production:</p>
<pre><code class="lang-dart"><span class="hljs-keyword">factory</span> FactoryLazySingleton({<span class="hljs-built_in">bool</span> isProduction = <span class="hljs-keyword">false</span>}) {
  <span class="hljs-keyword">if</span> (isProduction) {
    <span class="hljs-comment">// Apply production configuration</span>
    _instance.configure(productionSettings);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Apply development configuration</span>
    _instance.configure(devSettings);
  }
  <span class="hljs-keyword">return</span> _instance;
}
</code></pre>
<h4 id="heading-pros-2">Pros</h4>
<ol>
<li><p>You can add logic before returning an instance</p>
</li>
<li><p>You can cache or reuse the same object</p>
</li>
<li><p>You can dynamically return a subtype if needed</p>
</li>
<li><p>You avoid unnecessary instantiation</p>
</li>
<li><p>You can inject configuration or environment logic</p>
</li>
</ol>
<h4 id="heading-cons-2">Cons</h4>
<ol>
<li><p>Adds slight complexity compared to simple getter access</p>
</li>
<li><p>The factory constructor syntax might confuse developers unfamiliar with the pattern</p>
</li>
<li><p>If overused with complex logic, it can make debugging harder</p>
</li>
<li><p>Can create misleading code where <code>FactoryLazySingleton()</code> looks like it creates a new instance but doesn't</p>
</li>
</ol>
<h2 id="heading-when-not-to-use-a-singleton">When Not to Use a Singleton</h2>
<p>While singletons are powerful, they're not always the right solution. Understanding when to avoid them is just as important as knowing when to use them.</p>
<h3 id="heading-why-singletons-can-be-problematic">Why Singletons Can Be Problematic</h3>
<p>Singletons create global state, which can make your application harder to reason about and test. They introduce tight coupling between components that shouldn't necessarily know about each other, and they can make it difficult to isolate components for unit testing.</p>
<h3 id="heading-scenarios-where-you-should-avoid-singletons">Scenarios Where You Should Avoid Singletons</h3>
<p>Avoid using the Singleton pattern if:</p>
<h4 id="heading-you-need-multiple-independent-instances">You need multiple independent instances</h4>
<p>If different parts of your app need their own separate configurations or states, singletons force you into a one-size-fits-all approach.</p>
<p>For example, if you're building a multi-tenant application where each tenant needs isolated data, a singleton would cause data to bleed between tenants.</p>
<p><strong>Alternative</strong>: Use dependency injection to pass different instances to different parts of your app. Each component receives the specific instance it needs through its constructor or a service locator.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Instead of singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">UserRepository</span> </span>{
  <span class="hljs-keyword">final</span> DatabaseConnection db;
  UserRepository(<span class="hljs-keyword">this</span>.db); 
}

<span class="hljs-comment">// Usage</span>
<span class="hljs-keyword">final</span> dbForTenantA = DatabaseConnection(tenantId: <span class="hljs-string">'A'</span>);
<span class="hljs-keyword">final</span> dbForTenantB = DatabaseConnection(tenantId: <span class="hljs-string">'B'</span>);
<span class="hljs-keyword">final</span> repoA = UserRepository(dbForTenantA);
<span class="hljs-keyword">final</span> repoB = UserRepository(dbForTenantB);
</code></pre>
<h4 id="heading-your-architecture-avoids-shared-global-state">Your architecture avoids shared global state</h4>
<p>Modern architectural patterns like BLoC, Provider, or Riverpod in Flutter specifically aim to avoid global mutable state. Singletons work against these patterns by reintroducing global state.</p>
<p><strong>Alternative</strong>: Use state management solutions designed for Flutter. Provider, Riverpod, BLoC, or GetX offer better ways to share data across your app while maintaining testability and avoiding tight coupling.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Using Provider instead of singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppConfig</span> </span>{
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> apiUrl;
  AppConfig(<span class="hljs-keyword">this</span>.apiUrl);
}

<span class="hljs-comment">// Provide it at the top level</span>
<span class="hljs-keyword">void</span> main() {
  runApp(
    Provider&lt;AppConfig&gt;(
      create: (_) =&gt; AppConfig(<span class="hljs-string">'https://api.example.com'</span>),
      child: MyApp(),
    ),
  );
}

<span class="hljs-comment">// Access it anywhere in the widget tree</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MyWidget</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">StatelessWidget</span> </span>{
  <span class="hljs-meta">@override</span>
  Widget build(BuildContext context) {
    <span class="hljs-keyword">final</span> config = Provider.of&lt;AppConfig&gt;(context);

  }
}
</code></pre>
<h4 id="heading-it-forces-tight-coupling-between-unrelated-classes">It forces tight coupling between unrelated classes</h4>
<p>When multiple unrelated classes depend on the same singleton, they become indirectly coupled. Changes to the singleton affect all these classes, making the codebase fragile and hard to refactor.</p>
<p><strong>Alternative</strong>: Use interfaces and dependency injection. Define what behavior you need through an interface, then inject implementations. This way, classes depend on abstractions, not concrete singletons.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Define an interface</span>
<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message);
}

<span class="hljs-comment">// Implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ConsoleLogger</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-meta">@override</span>
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message) =&gt; <span class="hljs-built_in">print</span>(message);
}

<span class="hljs-comment">// Classes depend on the interface, not a singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">PaymentService</span> </span>{
  <span class="hljs-keyword">final</span> Logger logger;
  PaymentService(<span class="hljs-keyword">this</span>.logger);

  <span class="hljs-keyword">void</span> processPayment() {
    logger.log(<span class="hljs-string">'Processing payment'</span>);
  }
}

<span class="hljs-comment">// Easy to test with mock</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MockLogger</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-built_in">List</span>&lt;<span class="hljs-built_in">String</span>&gt; logs = [];
  <span class="hljs-meta">@override</span>
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message) =&gt; logs.add(message);
}
</code></pre>
<h4 id="heading-you-need-clean-isolated-testing">You need clean, isolated testing</h4>
<p>Singletons maintain state between tests, causing test pollution where one test affects another. This makes tests unreliable and order-dependent.</p>
<p><strong>Alternative</strong>: Use dependency injection and create fresh instances for each test. Most testing frameworks support this pattern, allowing you to inject mocks or fakes easily.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Testable code</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
  <span class="hljs-keyword">final</span> PaymentProcessor processor;
  OrderService(<span class="hljs-keyword">this</span>.processor);
}

<span class="hljs-comment">// In tests</span>
<span class="hljs-keyword">void</span> main() {
  test(<span class="hljs-string">'processes order successfully'</span>, () {
    <span class="hljs-keyword">final</span> mockProcessor = MockPaymentProcessor();
    <span class="hljs-keyword">final</span> service = OrderService(mockProcessor); 

  });
}
</code></pre>
<h3 id="heading-general-guidelines">General Guidelines</h3>
<p>Use singletons sparingly and only when you truly need exactly one instance of something for the entire application lifecycle. Good candidates include logging systems, application-level configuration, and hardware interface managers.</p>
<p>For most other cases, prefer dependency injection, state management solutions, or simply passing instances where needed. These approaches make your code more flexible, testable, and maintainable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Singleton pattern is a powerful creational tool, but like every tool, you should use it strategically.</p>
<p>Overusing singletons can make apps tightly coupled, hard to test, and less maintainable.</p>
<p>But when used correctly, the Singleton pattern helps you save memory, enforce consistency, and control object lifecycle beautifully.</p>
<p>The key is understanding your specific use case and choosing the right implementation approach – whether eager, lazy, or factory-based – that best serves your application's needs while maintaining clean, testable code.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Mobile App Development with Dart and Flutter ]]>
                </title>
                <description>
                    <![CDATA[ Mobile app development lets you build applications that run on multiple platforms. Flutter is Google's UI toolkit for building applications for mobile, web, and desktop from a single codebase. Flutter apps are written in Dart, a statically typed, obj... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/mobile-app-development-with-dart-and-flutter/</link>
                <guid isPermaLink="false">6902ba897614003427892ef9</guid>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mark Mahoney ]]>
                </dc:creator>
                <pubDate>Thu, 30 Oct 2025 01:08:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761786494585/5f335412-1621-4d5c-9861-29390381797c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile app development lets you build applications that run on multiple platforms. Flutter is Google's UI toolkit for building applications for mobile, web, and desktop from a single codebase. Flutter apps are written in Dart, a statically typed, object-oriented language.</p>
<p>Modern mobile development requires understanding widgets, state management, navigation, and data storage. The ecosystem includes thousands of free packages that give you access to device sensors, cloud services, and more.</p>
<p>This tutorial covers Dart fundamentals, Flutter basics, and some data storage options. There are 20 programs that walk you through building mobile apps from scratch. It’s built around a group of annotated <em>code playbacks</em>.</p>
<p>Each playback shows how I built a program step-by-step. They include a built-in AI tutor if you have any questions that I didn't cover. For a quick demo of how code playbacks work, watch this short video:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/uYbHqCNjVDM" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<p>You can access the free tutorial here: <a target="_blank" href="https://playbackpress.com/books/flutterbook">https://playbackpress.com/books/flutterbook</a></p>
<p><strong>Prerequisites</strong>: This is not a general introduction to programming, so you’ll need some basic programming knowledge to follow along. If you understand variables, loops, functions, and classes in any language, you should be fine. If you need an introduction to programming, check out my other tutorials in <a target="_blank" href="https://playbackpress.com/books/cppbook">C++</a> or <a target="_blank" href="https://playbackpress.com/books/pybook">Python</a> on <a target="_blank" href="https://playbackpress.com/books">Playback Press</a>.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<h3 id="heading-1-dart"><strong>1. Dart</strong></h3>
<ul>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/1">1.1 Hello World!!! and Flutter/Dart Install Instructions</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/2">1.2 Simple Types in Dart</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/3">1.3 Lists (Array Based Containers)</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/4">1.4 Maps and Sets</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/5">1.5 Altering the Flow of Control</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/6">1.6 Closures</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/7">1.7 Asynchronous Code in Dart</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/1/8">1.8 Classes in Dart</a></p>
</li>
</ul>
<h3 id="heading-2-flutter"><strong>2. Flutter</strong></h3>
<ul>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/1">2.1 Flutter Hello World</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/2">2.2 flutter create demo_app</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/3">2.3 ListViews</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/4">2.4 Laying Out Widgets</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/5">2.5 Navigation in Flutter</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/6">2.6 Forms</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/2/7">2.7 Using Packages in Flutter</a></p>
</li>
</ul>
<h3 id="heading-3-storing-an-apps-data"><strong>3. Storing an App's Data</strong></h3>
<ul>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/3/1">3.1 Storing App Data in a File</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/3/2">3.2 Storing App Data in a SQLite Database</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/3/3">3.3 Storing App Data in a Server</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/3/4">3.4 Storing App Data in the Firebase Cloud Firestore</a></p>
</li>
<li><p><a target="_blank" href="https://playbackpress.com/books/flutterbook/chapter/3/5">3.5 Firebase Authentication</a></p>
</li>
</ul>
<h2 id="heading-get-started"><strong>Get Started</strong></h2>
<p>Start with the Dart chapter if you're new to the language. If you have JavaScript, Java, or C# experience, it should feel familiar. Then, move on to the Flutter playbacks. Work through the playbacks at your own pace.</p>
<p>Questions or feedback? Contact me at <a target="_blank" href="mailto:mark@playbackpress.com">mark@playbackpress.com</a></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
