← All tech

Mobile / UI

Flutter

One Dart codebase, pixel-identical UI across iOS, Android, web, and desktop.

  • One codebase shipping to iOS and Android at once
  • Pixel-identical, brand-driven UI on every platform
  • Sub-second hot reload for tight design iteration
  • Custom animations and motion with full control
Use it when

Reach for Flutter when one team must ship a polished, brand-consistent app to both iOS and Android (and maybe web/desktop) fast, and you want the exact same pixels and motion everywhere with a single hot-reloadable codebase.

Reach for something else when

Skip Flutter when you need deep, day-one platform-native look-and-feel and the newest OS widgets the moment they ship, when the app is single-platform (use the native toolkit), or when most of your value lives in heavy native SDKs you'd spend more time bridging than building.

Official docs ↗


Flutter is the cross-platform pick of this curriculum: one Dart codebase that draws its own pixels with the Impeller renderer, so iOS and Android (and web and desktop) look and move identically. This track goes from “hello, widget” to state management, animation, platform channels, and a profiled release build — tagged by level so you can read only as deep as you need.

Install Flutter and create the app

Beginner

Install the Flutter SDK, run flutter doctor to check your toolchain, then scaffold an app with flutter create.

Why flutter doctor first

Flutter bundles the Dart SDK and a CLI, but it depends on platform toolchains you install separately — Xcode for iOS, the Android SDK for Android. flutter doctor inspects each one and prints exactly what is missing, so you fix setup before you write a line of code. flutter create then generates a runnable counter app: lib/main.dart, an ios/ and android/ host project, and a pubspec.yaml that declares your dependencies and assets.

Scaffold and verify
Run these in your terminal / editor
# Verify the toolchain and see what (if anything) is missing
flutter doctor

flutter create tidal_app
cd tidal_app
flutter run   # pick a connected device or simulator/emulator

Build your first screen from widgets

Beginner

Replace the generated body with your own StatelessWidget whose build method returns a Scaffold. In Flutter, everything you see is a widget.

Everything is a widget, composition over inheritance

Flutter’s core idea: the UI is a tree of widgets, and you build complex screens by composing small widgets rather than subclassing big ones. A StatelessWidget describes UI that depends only on its inputs; its build(BuildContext context) method returns a new widget subtree every time Flutter needs to paint. Scaffold lays out the app-bar/body/FAB structure of a Material screen, and widgets like Column, Padding, and Text nest inside it. Note build is pure: it reads inputs and returns widgets, with no side effects — Flutter may call it often.

A stateless screen
Run these in your terminal / editor
// lib/main.dart
import 'package:flutter/material.dart';

void main() => runApp(const TidalApp());

class TidalApp extends StatelessWidget {
  const TidalApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Tidal',
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tidal')),
      body: const Center(child: Text('Hello, widget')),
    );
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer working in this repo.
Context: Fresh app created with `flutter create tidal_app`; lib/main.dart still holds the generated counter.
Task: Replace main.dart with a clean MaterialApp whose home is a HomeScreen StatelessWidget showing a centered greeting.
Requirements:
- HomeScreen extends StatelessWidget with a const constructor taking super.key.
- The screen uses Scaffold + AppBar(title: Text('Tidal')) and a Center(child: Text(...)).
- No StatefulWidget, no business logic; build() stays pure (no side effects).
- Use const wherever the widget is constant.
Tests / acceptance:
- `flutter analyze` reports no issues.
- `flutter run` launches and the app bar reads "Tidal" with the greeting centered.
Output: a unified diff plus a one-paragraph summary of the widget tree.

Hold state with setState

Beginner

Convert the screen to a StatefulWidget and update a counter inside setState. Calling setState tells Flutter to rebuild.

Stateless vs Stateful, and what setState actually does

A StatefulWidget pairs an immutable widget with a mutable State object that survives rebuilds. You keep changing data (a counter, a toggle, a form value) as fields on the State, and you mutate them inside setState(() { ... }). setState marks the element dirty so Flutter schedules build again on the next frame; the framework then diffs the new widget tree against the old one and repaints only what changed. This is enough for local, screen-scoped state. When state must be shared across many widgets, reach for a state-management package (next step) instead of threading callbacks through the tree.

A counter with setState
Run these in your terminal / editor
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _count = 0;

  void _increment() => setState(() => _count++);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tidal')),
      body: Center(child: Text('Count: $_count')),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer in this repo.
Context: HomeScreen is currently a StatelessWidget in lib/main.dart.
Task: Convert HomeScreen to a StatefulWidget that counts taps on a FloatingActionButton.
Requirements:
- A private _count int on the State; increment it only inside setState.
- The body shows the current count; the FAB triggers the increment.
- build() remains free of side effects; no timers or I/O.
Tests / acceptance:
- `flutter analyze` clean.
- Add a widget test (test/home_test.dart): pump the app, tap the FAB once via WidgetTester, expect find.text('Count: 1') to find one widget; `flutter test` passes.
Output: a unified diff plus a one-paragraph note on why setState triggers a rebuild.

Iterate fast with hot reload

Beginner

Run the app, change a color or a string in your editor, and save. Hot reload pushes the change in under a second while keeping your current state.

Hot reload vs hot restart

Hot reload is Flutter’s signature feedback loop. On save it recompiles the changed Dart, injects it into the running Dart VM, and rebuilds the widget tree — preserving your app state, so your counter keeps its value and you stay on the same screen. It’s ideal for tuning UI and layout. Some changes can’t be hot reloaded (altering main(), global state initialisers, or const values, or changing class hierarchies); for those press hot restart (R), which resets state and re-runs from main. The press keys appear in the flutter run console.

The reload loop
Run these in your terminal / editor
flutter run
# In the running console:
#   r  -> hot reload (keep state)
#   R  -> hot restart (reset state)
#   q  -> quit

Manage app state with Riverpod

Intermediate

Add the flutter_riverpod package, wrap the app in a ProviderScope, and read shared state from any widget with a Consumer. This lifts state out of individual screens.

Why a state-management package beats threading setState

setState is great for one widget, but it can’t share state across screens without passing callbacks down and results back up the tree. Riverpod (from pub.dev) holds state in providers that any widget can read, watch, or update, with compile-time safety and easy testability. You wrap the root in a ProviderScope, declare a provider (here a Notifier that owns an int), and a ConsumerWidget’s build receives a ref to watch it — rebuilding only that widget when the value changes. Bloc and Provider are popular alternatives; the same principle holds — keep mutable state out of the widgets and in a testable object.

A counter provider
Run these in your terminal / editor
flutter pub add flutter_riverpod
import 'package:flutter_riverpod/flutter_riverpod.dart';

final counterProvider = NotifierProvider<Counter, int>(Counter.new);

class Counter extends Notifier<int> {
  @override
  int build() => 0;
  void increment() => state++;
}

void main() => runApp(const ProviderScope(child: TidalApp()));

class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Scaffold(
      body: Center(child: Text('Count: $count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer in this repo.
Context: App uses local setState for a counter. We want shared, testable state via flutter_riverpod.
Task: Introduce a counterProvider (Notifier<int>) and migrate HomeScreen to a ConsumerWidget that watches it.
Requirements:
- Wrap the root widget in ProviderScope in main().
- Counter extends Notifier<int>, build() returns 0, increment() mutates state.
- HomeScreen reads with ref.watch and increments via ref.read(counterProvider.notifier).
- Remove the old StatefulWidget counter; no setState left for this feature.
Tests / acceptance:
- `flutter analyze` clean.
- A test using ProviderContainer reads counterProvider, calls increment() twice, and expects 2; `flutter test` passes.
Output: a unified diff plus a one-paragraph note on watch vs read.

Lay out a scrollable, responsive list

Intermediate

Render a long list efficiently with ListView.builder, and adapt the layout to width with LayoutBuilder or MediaQuery. Only visible rows are built.

Lazy lists and const constructors for performance

ListView.builder builds items on demand as they scroll into view instead of constructing the whole list up front — essential for feeds of unknown length. Two performance habits matter here. First, mark widgets const whenever their configuration is fixed: a const widget is canonicalised, so Flutter can skip rebuilding and re-diffing it entirely. Second, adapt to screen size with LayoutBuilder (gives you the parent’s constraints) or MediaQuery.sizeOf(context) so a phone shows one column and a tablet shows a grid — the same Dart code, responsive everywhere.

A lazily built list
Run these in your terminal / editor
class FeedList extends StatelessWidget {
  const FeedList({super.key, required this.items});
  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) => ListTile(
        leading: const Icon(Icons.waves),
        title: Text(items[index]),
      ),
    );
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer in this repo.
Context: We need a performant feed list that adapts between phone and tablet widths.
Task: Build a FeedList widget that uses ListView.builder on narrow screens and a 2-column GridView on wide ones.
Requirements:
- Use LayoutBuilder (or MediaQuery.sizeOf) with a breakpoint of 600 logical pixels.
- Items are built lazily (builder form), not all at once.
- Mark every constant child widget const; the widget takes a required List<String> items.
Tests / acceptance:
- `flutter analyze` clean.
- A widget test pumps FeedList with 50 items at a narrow size and asserts find.byType(ListView) finds one; at a 700px wide surface it finds a GridView; `flutter test` passes.
Output: a unified diff plus a one-paragraph note on why const matters for list rows.

Fetch JSON and parse it into a model

Intermediate

Add the http package, call an API, and parse the response into a typed Dart class with a fromJson factory. Render the result with a FutureBuilder.

Typed models beat passing maps around

Dart’s jsonDecode returns dynamic Map<String, dynamic> data; pushing those untyped maps through your UI is fragile. The idiom is a small model class with a factory Model.fromJson(Map<String, dynamic> json) constructor that pulls fields out once, so the rest of the app works with real types. http.get returns a Future, and FutureBuilder rebuilds as that future resolves — exposing loading, error, and data states so you handle each explicitly instead of flashing a blank screen.

Fetch and decode
Run these in your terminal / editor
flutter pub add http
import 'dart:convert';
import 'package:http/http.dart' as http;

class Post {
  const Post({required this.id, required this.title});
  final int id;
  final String title;

  factory Post.fromJson(Map<String, dynamic> json) =>
      Post(id: json['id'] as int, title: json['title'] as String);
}

Future<List<Post>> fetchPosts() async {
  final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'));
  if (res.statusCode != 200) {
    throw Exception('Request failed: ${res.statusCode}');
  }
  final data = jsonDecode(res.body) as List<dynamic>;
  return data.map((j) => Post.fromJson(j as Map<String, dynamic>)).toList();
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Flutter teacher. The reader has no repo access here — return complete code.
Task: Show an idiomatic Post model with fromJson, an async fetchPosts() using the http package, and a screen that renders the result with FutureBuilder handling loading / error / data states.
Requirements:
- Post is immutable with a const constructor and a factory Post.fromJson(Map<String, dynamic>).
- fetchPosts throws on a non-200 status and returns Future<List<Post>>.
- The FutureBuilder shows a CircularProgressIndicator while waiting, an error Text on error, and a ListView on data.
Tests / acceptance (describe, since no repo):
- A unit test of Post.fromJson on a sample map yields the expected id/title.
- A non-200 response surfaces as an error state, not a crash.
Output: the complete Dart file, no commentary.

Animate with an AnimationController

Advanced

Drive a custom animation with an AnimationController inside a StatefulWidget, and dispose it to avoid leaks. Flutter draws every frame itself, so motion is yours to design.

Why Flutter owns animation end-to-end

Because Flutter renders its own pixels through the Impeller engine rather than mapping to OS widgets, every animation runs the same on iOS and Android — no platform fudging. An AnimationController is the clock: it produces values from 0→1 over a Duration, driven by a vsync ticker bound to the screen’s refresh rate. You feed those values into widgets (opacity, scale, position) via an AnimatedBuilder. The controller holds resources, so you create it in initState and must release it in dispose — forget that and you leak a ticker. For one-off transitions, implicit widgets like AnimatedContainer do this for you; reach for a controller when you need fine, repeatable control.

A pulsing icon
Run these in your terminal / editor
class Pulse extends StatefulWidget {
  const Pulse({super.key});
  @override
  State<Pulse> createState() => _PulseState();
}

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 800),
  )..repeat(reverse: true);

  @override
  void dispose() {
    _c.dispose(); // release the ticker
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: Tween(begin: 0.8, end: 1.2).animate(_c),
      child: const Icon(Icons.waves, size: 64),
    );
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer in this repo.
Context: We want a reusable pulsing icon driven by an explicit AnimationController.
Task: Implement a Pulse StatefulWidget that scales an icon between 0.8 and 1.2 on an 800ms repeating, reversing animation.
Requirements:
- Use SingleTickerProviderStateMixin and create the controller in a field or initState.
- Dispose the controller in dispose(); the analyzer must not flag a leaked controller.
- Use ScaleTransition or AnimatedBuilder; no third-party animation package.
Tests / acceptance:
- `flutter analyze` clean (no must-call-super or dispose warnings).
- A widget test pumps Pulse, advances time with tester.pump(Duration(milliseconds: 400)), and asserts the widget tree still contains the Icon; `flutter test` passes.
Output: a unified diff plus a one-paragraph note on why dispose is mandatory.

Call native code over a platform channel

Advanced

When you need an OS feature Flutter doesn’t wrap, invoke native Kotlin/Swift through a MethodChannel. Dart calls; the platform answers.

When to drop to the platform, and how the channel works

Flutter draws its own UI, but some capabilities live only in native SDKs (a specific sensor, a vendor SDK, a platform API with no plugin yet). A MethodChannel is the bridge: you name a channel string shared between Dart and the host, call invokeMethod from Dart, and implement a handler in MainActivity.kt (Android) and AppDelegate.swift (iOS) that returns a result asynchronously. Always check pub.dev first — a maintained plugin usually already wraps the feature — and reserve hand-written channels for the genuinely uncovered cases, since each one is platform-specific code you now maintain twice.

Invoke a native method from Dart
Run these in your terminal / editor
import 'package:flutter/services.dart';

const _channel = MethodChannel('tidal/device');

Future<String> osVersion() async {
  try {
    return await _channel.invokeMethod<String>('getOsVersion') ?? 'unknown';
  } on PlatformException catch (e) {
    return 'error: ${e.message}';
  }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter engineer comfortable with Kotlin and Swift, in this repo.
Context: We need the host OS version string, which has no plugin wired up. Channel name: "tidal/device", method "getOsVersion".
Task: Implement a MethodChannel round trip: Dart osVersion(), Android handler in MainActivity.kt, iOS handler in AppDelegate.swift.
Requirements:
- Dart side wraps invokeMethod and catches PlatformException, returning a fallback string.
- Android returns "Android ${Build.VERSION.RELEASE}"; iOS returns UIDevice.current.systemVersion.
- Unknown method names call result.notImplemented() / FlutterMethodNotImplemented.
Tests / acceptance:
- `flutter analyze` clean; the Android and iOS host projects compile.
- Manual: calling osVersion() on each platform returns a non-empty version string, never throws.
Output: a unified diff across the Dart, Kotlin, and Swift files plus a one-paragraph note on channel naming.

Profile and ship a release build

Advanced

Run in profile mode to measure real frame times, then build a signed release artifact for each store. Release builds use AOT-compiled machine code, not the debug JIT.

Debug vs profile vs release, and what AOT buys you

Flutter runs three ways. Debug uses the Dart VM’s JIT to enable hot reload, but its timings are not representative. Profile mode strips debug aids while keeping tracing, so the DevTools timeline shows true frame build/raster times — this is how you hunt jank (frames over the 16ms budget at 60Hz). Release mode AOT-compiles Dart to native machine code for fast startup and steady frames, and is what you upload. Build an Android App Bundle (appbundle) for Play and an ipa for the App Store; both need signing configured in the respective host project.

Profile, then build release
Run these in your terminal / editor
# Measure on a real device with the DevTools performance overlay
flutter run --profile

# Store-ready artifacts
flutter build appbundle --release   # Android -> Play (.aab)
flutter build ipa --release         # iOS -> App Store (.ipa)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Flutter performance engineer in this repo.
Context: A scrolling feed drops frames (visible jank) on a mid-range Android device.
Task: Diagnose the jank in profile mode and propose one concrete fix backed by the timeline.
Requirements:
- Reproduce with `flutter run --profile` and the performance overlay / DevTools timeline; identify whether build or raster is over budget.
- Apply exactly one fix the timeline justifies (e.g. add const to rebuilt rows, cache an image, or use RepaintBoundary) — do not change behaviour.
- Note the 16ms-per-frame target at 60Hz in the rationale.
Tests / acceptance:
- `flutter analyze` clean and `flutter test` still green.
- Report before/after frame build times from the DevTools timeline for the same scroll.
Output: a unified diff plus the before/after frame-time numbers and which phase improved.

Where to take it next

  • Ship the Flutter client of a guided project — e.g. the storefront in Aurora Commerce — one Dart codebase across iOS and Android.
  • Prefer fully native UI per platform? Compare with Jetpack Compose for Android and SwiftUI for Apple platforms.
  • Want to share business logic but keep native UI? See the multiplatform path in Kotlin.