Bryn Flow

🐦 Learn Flutter

14 core topics for building beautiful, natively-compiled apps for mobile, web, and desktop from one codebase. Each topic includes a short explanation and a working code sample.

← All Tracks

1 Widgets Basics

In Flutter, everything you see on screen β€” text, padding, buttons, even layout structure β€” is a widget. Widgets are immutable descriptions of part of the UI, and Flutter builds a tree of them to render your app. You compose small widgets together to build bigger ones.

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Hello Flutter')),
        body: const Center(
          child: Text('Everything is a widget!'),
        ),
      ),
    );
  }
}

Even MaterialApp and Scaffold are just widgets that wrap other widgets.

2 StatelessWidget vs StatefulWidget

A StatelessWidget is built once from its inputs and never changes on its own β€” great for static UI. A StatefulWidget keeps a mutable State object that can call setState() to trigger a rebuild when data changes, such as a counter or a toggle.

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_count'),
        ElevatedButton(onPressed: _increment, child: const Text('+1')),
      ],
    );
  }
}

Use StatelessWidget by default and reach for StatefulWidget only when a widget truly owns mutable state.

3 Layout Widgets (Row, Column, Stack, Container)

Row and Column arrange children horizontally or vertically. Stack layers children on top of one another, useful for overlays like badges on an icon. Container is a general-purpose box for padding, margin, size, and decoration around a single child.

Widget build(BuildContext context) {
  return Container(
    padding: const EdgeInsets.all(16),
    color: Colors.grey[200],
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: const [Icon(Icons.star), Text('Featured')],
        ),
        Stack(
          alignment: Alignment.topRight,
          children: [
            const Icon(Icons.notifications, size: 40),
            Container(
              padding: const EdgeInsets.all(4),
              decoration: const BoxDecoration(
                color: Colors.red,
                shape: BoxShape.circle,
              ),
              child: const Text('3', style: TextStyle(color: Colors.white)),
            ),
          ],
        ),
      ],
    ),
  );
}

Rows overflow if their children don't fit β€” wrap contents in Expanded or Flexible when needed.

4 State Management with setState

setState() is Flutter's simplest way to manage state: it tells the framework that a widget's internal data changed, so it should rebuild that widget's subtree. It works well for small, localized state that doesn't need to be shared across many widgets.

class LikeButton extends StatefulWidget {
  const LikeButton({super.key});

  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> {
  bool _liked = false;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(_liked ? Icons.favorite : Icons.favorite_border),
      color: _liked ? Colors.red : Colors.grey,
      onPressed: () {
        setState(() {
          _liked = !_liked;
        });
      },
    );
  }
}

Only call setState() inside the State object that owns the data β€” never on a widget elsewhere in the tree.

5 Provider (App-Level State Management)

The provider package lets you share state across many widgets without manually passing it down through constructors. You expose a ChangeNotifier at the top of the tree, and any descendant widget can read or listen to it with context.watch or Provider.of.

class CartModel extends ChangeNotifier {
  int _items = 0;
  int get items => _items;

  void add() {
    _items++;
    notifyListeners();
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(),
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartModel>();
    return Text('Items: ${cart.items}');
  }
}

context.watch() rebuilds the widget on every change; use context.read() inside callbacks when you don't need a rebuild.

7 Forms & Validation

A Form widget groups TextFormField inputs and validates them together. A GlobalKey<FormState> lets you trigger validation and read the form's current state from outside the build method, such as in a submit button's onPressed.

final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();

Widget build(BuildContext context) {
  return Form(
    key: _formKey,
    child: Column(
      children: [
        TextFormField(
          controller: _emailController,
          decoration: const InputDecoration(labelText: 'Email'),
          validator: (value) {
            if (value == null || !value.contains('@')) {
              return 'Enter a valid email';
            }
            return null;
          },
        ),
        ElevatedButton(
          onPressed: () {
            if (_formKey.currentState!.validate()) {
              print('Email: ${_emailController.text}');
            }
          },
          child: const Text('Submit'),
        ),
      ],
    ),
  );
}

Always dispose() TextEditingControllers in the State's dispose() method to avoid memory leaks.

8 Lists with ListView.builder

ListView.builder creates list items lazily β€” only the items currently visible on screen are built β€” which makes it efficient for long or infinite lists, unlike a plain ListView with all children created up front.

class ContactsList extends StatelessWidget {
  final List<String> contacts;
  const ContactsList({super.key, required this.contacts});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: contacts.length,
      itemBuilder: (context, index) {
        return ListTile(
          leading: const Icon(Icons.person),
          title: Text(contacts[index]),
          onTap: () => print('Tapped ${contacts[index]}'),
        );
      },
    );
  }
}

Add itemExtent when every row has a fixed height β€” it lets Flutter scroll more efficiently.

9 Animations

AnimatedContainer is the easiest way to animate simple property changes β€” just change a value like width or color, and it interpolates automatically. For finer control, an explicit AnimationController drives more complex, custom animations.

class GrowBox extends StatefulWidget {
  const GrowBox({super.key});

  @override
  State<GrowBox> createState() => _GrowBoxState();
}

class _GrowBoxState extends State<GrowBox> {
  bool _big = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _big = !_big),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        width: _big ? 200 : 100,
        height: _big ? 200 : 100,
        color: _big ? Colors.blue : Colors.orange,
      ),
    );
  }
}

Reach for AnimationController + AnimatedBuilder when you need to chain, repeat, or precisely control timing.

10 Networking with http

The http package makes REST calls straightforward. You send a request, await the response, and typically decode JSON with dart:convert into Dart objects for use in your widgets.

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

Future<List<String>> fetchUserNames() async {
  final response = await http.get(
    Uri.parse('https://api.example.com/users'),
  );

  if (response.statusCode == 200) {
    final List<dynamic> data = jsonDecode(response.body);
    return data.map((user) => user['name'] as String).toList();
  } else {
    throw Exception('Failed to load users');
  }
}

Always check statusCode and wrap network calls in try/catch to handle timeouts or connectivity errors gracefully.

11 Local Storage (shared_preferences)

The shared_preferences package persists small pieces of data β€” like settings or a flag β€” to disk as simple key-value pairs. It's asynchronous and works on both mobile and web, but it's not meant for large or structured data.

import 'package:shared_preferences/shared_preferences.dart';

Future<void> saveUsername(String name) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('username', name);
}

Future<String> loadUsername() async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getString('username') ?? 'Guest';
}

For larger structured data, consider a local database package like sqflite or hive instead.

12 Theming

A ThemeData object passed to MaterialApp centralizes your app's colors, typography, and widget styles so you don't repeat them everywhere. Widgets like Text and ElevatedButton automatically pick up theme values unless overridden.

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
    textTheme: const TextTheme(
      titleLarge: TextStyle(fontWeight: FontWeight.bold),
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.teal,
        foregroundColor: Colors.white,
      ),
    ),
  ),
  darkTheme: ThemeData.dark(),
  home: const HomeScreen(),
);

Access the current theme anywhere with Theme.of(context) instead of hardcoding colors in widgets.

13 Futures & FutureBuilder

A Future represents a value that will be available later, such as a network response. FutureBuilder connects a Future directly to your UI, rebuilding automatically as it moves from loading, to data, to error states.

class UserProfile extends StatelessWidget {
  final Future<String> userNameFuture;
  const UserProfile({super.key, required this.userNameFuture});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: userNameFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        } else {
          return Text('Hello, ${snapshot.data}');
        }
      },
    );
  }
}

Pass the Future in from a field or initState β€” never call the async function directly inside build(), or it will restart on every rebuild.

14 Streams & StreamBuilder

A Stream is like a Future that can emit multiple values over time β€” perfect for things like live chat messages or a countdown timer. StreamBuilder listens to a stream and rebuilds the UI each time a new value arrives.

Stream<int> countdown() async* {
  for (int i = 10; i >= 0; i--) {
    await Future.delayed(const Duration(seconds: 1));
    yield i;
  }
}

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

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: countdown(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const Text('Starting…');
        return Text('${snapshot.data}', style: const TextStyle(fontSize: 48));
      },
    );
  }
}

StreamBuilder automatically unsubscribes when the widget is removed from the tree, so you don't need to manage that manually.

Practice Quiz

Test what you just learned about Flutter.

Ready for another track?

Explore Android, iOS, React Native, or Dart next.

Back to All Tracks