Building Your First Flutter App: Step by Step

Building Your First Flutter App: Step by Step
# Download and install Flutter (Linux/macOS)
git clone https://github.com/flutter/flutter.git -b stable
export PATH="$PATH:$(pwd)/flutter/bin"

# Verify installation
flutter doctor

# Create a new project
flutter create my_first_app
cd my_first_app
flutter run

Paste that into your terminal. Go ahead, I’ll wait.

What just happened? flutter create scaffolded an entire mobile app — Dart files, Android configs, iOS configs, test folder, the whole thing — and flutter run launched it on whatever device or emulator you’ve got connected. You’ve already got a running app. No XML layouts. No Gradle nightmares. Just a project that works out of the gate.

Quick setup check: Run flutter doctor before anything else. It’ll scan your machine and flag missing pieces — Android SDK, Xcode, Chrome, connected devices. Fix whatever it complains about. On my machine back in late 2024, I wasted forty minutes because I’d forgotten to accept Android SDK licenses. Don’t be me. Run the doctor first.

Now here’s where it gets fun. Open lib/main.dart in your editor. Delete everything in there. Seriously, wipe it clean. We’re building from scratch, and I want you to feel every line.

Your First Widget (Yes, Everything Is a Widget)

Flutter’s core idea is absurdly simple: everything on screen is a widget. Buttons? Widgets. Text? Widget. Padding around your text? Also a widget. Even your entire app is a widget wrapped inside another widget. Sounds weird until you actually build something — then it clicks hard.

Two flavours exist. A StatelessWidget draws UI that doesn’t change. A StatefulWidget draws UI that updates when data shifts. We’ll hit both soon enough.

Start typing this into your empty main.dart:

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('My First App')),
        body: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.flutter_dash, size: 80, color: Colors.blue),
              SizedBox(height: 16),
              Text(
                'Welcome to Flutter!',
                style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
              ),
              SizedBox(height: 8),
              Text('Build beautiful apps with Dart'),
            ],
          ),
        ),
      ),
    );
  }
}

Save the file and hit hot reload (just press r in the terminal where flutter run is active). Boom. A blue app bar, the Flutter Dash icon, and a welcome message sitting dead center on screen.

Let me break down the pieces, because each one matters.

MaterialApp wraps your entire app in Google’s Material Design system. Gives you themes, navigation, and a bunch of sensible defaults for free. Scaffold sets up the basic visual structure — app bar on top, body below, space for floating buttons if you want them later. Pretty much every Flutter screen starts with a Scaffold.

Column stacks its children vertically. Center shoves its child to the middle of whatever space is available. And SizedBox? Just invisible spacing. Sixteen pixels of breathing room between the icon and the headline.

Widget nesting tip: Flutter code can look deeply nested — widgets inside widgets inside widgets. Don’t panic. Your editor probably has bracket matching and folding. Use them. After a few days, reading this tree structure becomes second nature. I promise.

See how everything composes? You didn’t configure a layout engine. Didn’t write CSS. You just described what goes where by nesting widgets, and Flutter figured out the rendering. That compositional model is, honestly, my favourite thing about the framework. It might seem verbose at first glance, but you always know exactly what’s happening on screen just by reading the code top to bottom.

Making Things Interactive: The Counter App

A static welcome screen is cool for about ten seconds. Let’s make something you can actually tap.

We’re building a counter. Tap a button, number goes up. Tap another, it goes down. Classic beginner project, sure, but it teaches the single most important concept in Flutter: state. And once state clicks for you, everything else in Flutter starts making sense.

Here’s where StatefulWidget enters the picture. Unlike our StatelessWidget from before, a StatefulWidget remembers things. It holds data that can change, and when that data changes, Flutter redraws the screen. Automatically. You don’t manually update DOM elements or call invalidate() or any of that. You just say “hey, my number changed” and Flutter handles the rest.

Replace the body of your app with this new page (or create a separate file — up to you):

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

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  void _increment() => setState(() => _count++);
  void _decrement() => setState(() {
    if (_count > 0) _count--;
  });
  void _reset() => setState(() => _count = 0);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Counter'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _reset,
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Current Count:', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 8),
            Text(
              '$_count',
              style: Theme.of(context).textTheme.displayLarge?.copyWith(
                fontWeight: FontWeight.bold,
                color: _count > 10 ? Colors.red : Colors.indigo,
              ),
            ),
            const SizedBox(height: 32),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FilledButton.tonalIcon(
                  onPressed: _decrement,
                  icon: const Icon(Icons.remove),
                  label: const Text('Decrease'),
                ),
                const SizedBox(width: 16),
                FilledButton.icon(
                  onPressed: _increment,
                  icon: const Icon(Icons.add),
                  label: const Text('Increase'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Run it. Tap “Increase” a few times. Watch that giant number climb. Now tap past 10 and — surprise! — the number turns red. A tiny visual trick, but it shows something powerful: your UI reacts to data in real time.

Alright, let’s unpack setState because it’s doing heavy lifting here. When you call setState, you’re telling Flutter: “I just changed something. Please re-run my build method.” Flutter then compares the new widget tree against the old one (it’s wickedly fast at this) and only repaints the pieces that actually changed. Not the whole screen. Just the bits that need updating.

Notice the conditional colour swap on line with _count > 10. Your UI isn’t static anymore. It’s alive, reactive, responding to the actual state of your data. And you didn’t write an if-statement somewhere separate to toggle a CSS class. The logic lives right there in the build method, alongside the layout. Clean.

Hot reload magic: While the app’s running, change the threshold from 10 to 5 and save. The number’s colour logic updates instantly — no restart, no state loss. Sub-second feedback loops like this are probably why Flutter devs tend to prototype so quickly.

Also catch that reset button in the app bar? It’s an IconButton sitting inside the actions list. Scaffold gives you that slot for free. Drop any widget there and it shows up on the right side of the app bar. Sweet little affordance.

Building a Real Todo List

Counters are great for learning setState. But you didn’t download Flutter to count to fifteen. Let’s build something meatier — a todo list with add, toggle, and swipe-to-delete. CRUD operations, basically, the backbone of almost every app you’ll ever ship.

First, we need a data class to hold each todo item:

class Todo {
  final String id;
  final String title;
  bool isDone;

  Todo({required this.title, this.isDone = false})
      : id = DateTime.now().millisecondsSinceEpoch.toString();
}

Simple enough. Each todo gets a unique ID based on the timestamp (good enough for a local app — you’d want UUIDs in production, probably), a title, and a done/not-done flag.

Now the page itself. This one’s bigger, so I’ll drop the whole thing and then walk through the interesting parts:

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

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  final List<Todo> _todos = [];
  final TextEditingController _controller = TextEditingController();

  void _addTodo() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;

    setState(() {
      _todos.insert(0, Todo(title: text));
      _controller.clear();
    });
  }

  void _toggleTodo(int index) {
    setState(() => _todos[index].isDone = !_todos[index].isDone);
  }

  void _deleteTodo(int index) {
    setState(() => _todos.removeAt(index));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final pending = _todos.where((t) => !t.isDone).length;

    return Scaffold(
      appBar: AppBar(
        title: Text('Todos ($pending pending)'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'What needs to be done?',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _addTodo(),
                  ),
                ),
                const SizedBox(width: 12),
                FilledButton(
                  onPressed: _addTodo,
                  child: const Text('Add'),
                ),
              ],
            ),
          ),
          Expanded(
            child: _todos.isEmpty
                ? const Center(child: Text('No todos yet. Add one above!'))
                : ListView.builder(
                    itemCount: _todos.length,
                    itemBuilder: (context, index) {
                      final todo = _todos[index];
                      return Dismissible(
                        key: ValueKey(todo.id),
                        onDismissed: (_) => _deleteTodo(index),
                        background: Container(
                          color: Colors.red.shade100,
                          alignment: Alignment.centerRight,
                          padding: const EdgeInsets.only(right: 16),
                          child: const Icon(Icons.delete, color: Colors.red),
                        ),
                        child: ListTile(
                          leading: Checkbox(
                            value: todo.isDone,
                            onChanged: (_) => _toggleTodo(index),
                          ),
                          title: Text(
                            todo.title,
                            style: TextStyle(
                              decoration: todo.isDone
                                  ? TextDecoration.lineThrough
                                  : null,
                              color: todo.isDone ? Colors.grey : null,
                            ),
                          ),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

So much to love here. Let me walk through what makes this tick.

TextEditingController manages the text field. It reads the current text, clears it after adding a todo, and — crucially — gets cleaned up in dispose(). Forgetting to dispose controllers is a classic Flutter memory leak. Don’t skip it. Future you will be grateful.

ListView.builder is Flutter’s way of rendering long scrollable lists efficiently. Instead of building all items upfront, it only creates the widgets currently visible on screen. Got a thousand todos? (Ambitious. I like it.) Only the ten or fifteen that fit on screen exist in memory at any given moment. Buttery smooth scrolling even on older phones.

And then there’s Dismissible — one of my favourite widgets in the entire framework. Wrap any list item in it, and you get swipe-to-delete for free. Swipe right, red background slides in with a trash icon, let go, item’s gone. The kind of interaction that would take fifty lines of JavaScript and a third-party library? Five lines in Flutter. I still find that sort of delightful even after using the framework for a while now.

Memory leak warning: Every TextEditingController, AnimationController, ScrollController, and FocusNode you create needs a matching dispose() call. Flutter won’t yell at you if you forget — your app will just quietly leak memory until it gets sluggish. Make it a habit: create controller, immediately write the dispose. Then fill in the middle.

Catch the pending count in the app bar? _todos.where((t) => !t.isDone).length recalculates every time the build method runs. So when you check off a task, the “3 pending” in the title drops to “2 pending” instantly. No separate update call. No event bus. Just Dart doing its thing inside build.

One more neat detail: _todos.insert(0, ...) adds new items at the top of the list instead of the bottom. Small UX choice, but it means you immediately see what you just added without scrolling. Little decisions like these make apps feel polished.

Growing Up: State Management with Provider

So setState works great inside a single page. But what happens when your app has five screens, and three of them need access to the same data? Passing everything through constructor parameters gets painful fast. Trust me — I tried it on a personal project once and ended up with constructors that had eight or nine parameters. Messy.

Enter Provider. It’s probably the most popular state management package in the Flutter ecosystem, and for good reason: it’s straightforward without being simplistic. You create a model class, wrap your app in a Provider, and any widget anywhere in the tree can read that model. No prop drilling. No callback chains.

Here’s our counter refactored to use Provider. You’ll need to add provider: ^6.1.0 to your pubspec.yaml first:

// Add to pubspec.yaml: provider: ^6.1.0

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  void decrement() {
    if (_count > 0) {
      _count--;
      notifyListeners();
    }
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const MaterialApp(home: ProviderCounterPage()),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    final counter = context.watch<CounterModel>();

    return Scaffold(
      appBar: AppBar(title: const Text('Provider Counter')),
      body: Center(
        child: Text(
          '${counter.count}',
          style: Theme.of(context).textTheme.displayLarge,
        ),
      ),
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton(
            heroTag: 'inc',
            onPressed: counter.increment,
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            heroTag: 'dec',
            onPressed: counter.decrement,
            child: const Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

Look at what changed. CounterModel extends ChangeNotifier — a class built into Flutter that lets you shout “I changed!” to anyone listening. When increment() bumps the count, it calls notifyListeners(), and every widget watching that model gets rebuilt. Automatically.

The huge win? CounterModel is pure Dart. No UI code in it. No references to widgets or BuildContext or any of that. You could unit-test this class without ever importing Flutter’s material library. Write a test, call increment() three times, assert count == 3. Done. Clean separation between your business logic and your visual layer.

And context.watch<CounterModel>() is doing something clever. It tells Flutter: “Rebuild this widget whenever CounterModel changes.” Compare that to our earlier approach where state lived inside the widget itself. With Provider, the state lives outside the widget tree entirely. Any page, any screen, any deeply nested component can access CounterModel without you threading it through five constructors.

When to reach for Provider: If your app has one screen with local state, setState is perfectly fine. Don’t overcomplicate things. But the moment you catch yourself passing the same data through three or four widgets just to get it to the one that needs it? That’s your cue. Provider (or Riverpod, or Bloc — the Flutter ecosystem has options) will save you a lot of headache.

Something I should mention: ChangeNotifierProvider wraps the entire MaterialApp here. That’s intentional. It makes the counter available to every route and screen in the app. You could scope it tighter if you wanted — wrap just one branch of the widget tree — but for a small app like this, top-level is fine. Maybe even preferable, since you might add more screens later.

How These Pieces Fit Together

Let’s zoom out for a second. In roughly 200 lines of Dart, you’ve built three things:

  1. A welcome screen using StatelessWidget and basic layout widgets
  2. An interactive counter with StatefulWidget, setState, and conditional styling
  3. A full todo list with text input, checkboxes, swipe-to-delete, and live pending counts

Plus you refactored the counter to use Provider for scalable state management.

That’s… kind of a lot for a single sitting? I remember spending my first weekend with Flutter back in late 2024 and being slightly shocked at how much I’d gotten done compared to my early days learning React Native. Hot reload alone probably saved me hours. And Dart turned out to be far more readable than I’d expected — somewhere between Java’s structure and JavaScript’s flexibility, with none of Java’s ceremony.

A few patterns worth flagging before we wrap up, because they’ll keep coming back as you build bigger things:

Widget composition over inheritance. You don’t extend Button to make a FancyButton. You compose: wrap a button in padding, in a container, in a gesture detector. Each widget does one thing. You stack them like LEGO bricks. Sounds tedious in theory, feels incredibly flexible in practice.

Build methods should be fast. Flutter calls build() a lot — any time state changes, any time a parent rebuilds. Keep it lightweight. Don’t do network calls in there. Don’t run heavy computations. Read your state, describe your UI, get out. Slow build methods mean janky animations and dropped frames.

Keys matter for lists. See how the todo list uses ValueKey(todo.id) on each Dismissible? That’s how Flutter tracks which list item is which across rebuilds. Without keys, things get weird — swipe-deleting one item might visually remove a different one. I learned this the hard way when my first list app had phantom items that wouldn’t go away. Keys fixed it instantly.

Where to go next: Flutter’s official docs at docs.flutter.dev are genuinely excellent — probably the best documentation of any mobile framework I’ve used. Bookmark the widget catalog. When you’re wondering “is there a widget for X?”, the answer is almost always yes, and it’s in that catalog.

Levelling Up: Concepts Just Around the Corner

You’ve nailed the basics. Widgets, state, layout, user input, list rendering, state management. Solid foundation. But Flutter goes so much deeper, and I want to at least point you toward the next set of skills so you know what to Google when you’re ready.

Navigation and routing. Right now everything lives on one screen. Real apps have multiple pages — a home screen, a detail view, a settings page. Flutter’s Navigator and the newer GoRouter package handle this. Push a new route onto the stack, pop it off when the user hits back. Similar to how browser history works, if that helps.

HTTP and APIs. Your todo list stores items in memory — close the app and they vanish. For real data persistence, you’ll want to talk to a backend. The http package (or dio for something beefier) lets you make GET, POST, PUT, DELETE requests. Combine that with FutureBuilder and you can show a loading spinner while data arrives, then render it once it lands.

Local storage. Not every app needs a server. shared_preferences handles simple key-value storage (think settings, flags, small counters). For structured local data, hive is fast and doesn’t need native configuration. Your todo list with Hive would survive app restarts without a backend at all.

Animations. Flutter’s animation system is outrageously powerful. Start with AnimatedContainer and AnimatedOpacity for simple stuff. When you’re feeling brave, AnimationController with Tween gives you frame-level control. I spent an entire Saturday last January just playing with Hero animations between screens. Couldn’t stop.

Platform-specific code. Need to access the camera? Read the battery level? Use GPS? Flutter’s platform channel system lets your Dart code talk to native Kotlin/Swift code. Or just grab a plugin from pub.dev — someone’s probably already built it.

Your Challenge: Extend the Todo App

Here’s my dare. Don’t just close this tab and nod along. Actually go build something.

Take the todo app we built and add categories. Work tasks, personal tasks, shopping list — whatever buckets make sense for your life. Let the user pick a category when adding a todo. Show filter buttons at the top that toggle between “All,” “Work,” “Personal,” and “Shopping.” Maybe change the list tile’s leading icon based on category. Colour-code them if you’re feeling fancy.

No walkthrough for this one. You’ve got the pieces: StatefulWidget for managing which filter is active, a category field on the Todo class, a DropdownButton or ChoiceChip row for selection, and .where() on the list to filter. Wire it together.

If you manage that in under an hour, tack on a priority system — high, medium, low — with sorting. And if you’re really on a roll, migrate the whole thing to Provider so the state survives across multiple screens.

Publish your version. Put it on GitHub. Tweet about it. Drop a link in the comments below.

Because the gap between “I followed a tutorial” and “I built something myself” is where actual learning happens. You’ve got the foundation. Now go break things, fix them, and break them again. That’s the loop. That’s how every Flutter dev I know — including me — actually got good at this.

See you in the next build.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.