Home Introduction to Flutter UI Widget Fundamentals

🧩 Widget Fundamentals — Build Your Own Custom Widgets

⏱️ 30-35 minutes 📊 Beginner — You'll create custom widgets! 📦 2 Projects included 🏷️ widgets, StatelessWidget, Container, BoxDecoration

🧒 What Are Widgets? (The LEGO Analogy Returns!)

🧱

Widgets Are Like LEGO Bricks — But Smarter!

In the last lesson, you learned that everything in Flutter is a widget. Now you're going to learn how to create your own custom widgets — just like designing your own special LEGO piece that you can reuse over and over!

Today, you'll build a "Tile" widget for the Birdle game — a colored square that shows a letter and changes color based on whether the guess is correct (green 🟩), partially correct (yellow 🟨), or wrong (gray ⬜).

🎯
By the end of this lesson, you will:
  • ✅ Create a custom StatelessWidget from scratch
  • ✅ Use constructor parameters to make widgets reusable
  • ✅ Style widgets with Container and BoxDecoration
  • ✅ Understand how data flows into widgets
🏗️

The Anatomy of Every Widget

Every custom widget you create has three essential parts:

📋

1. Constructor

The "delivery address" — this is where you tell the widget what data it needs. Like ordering a pizza: "I want a large pepperoni!" 🍕

🏠

2. Properties (Fields)

The "storage closet" — the widget remembers the data you gave it so it can use it later when drawing itself.

🎨

3. Build Method

The "artist studio" — this is where the widget actually draws itself on screen. Every widget MUST have this!

🎁

Container + BoxDecoration = Beautiful Widgets!

Think of a Container like a gift box 📦. It has:

  • Size — how big the box is (width & height)
  • Decoration — the wrapping paper and ribbon (BoxDecoration)
  • Child — what's inside the box (another widget!)

BoxDecoration is the "wrapping paper" — it controls borders, background colors, shadows, and rounded corners. Together, they make your widgets look professional!

0 Before You Start — Add the Game Logic File

Before we build the UI, we need to add a file that handles the game logic (the behind-the-scenes rules for Birdle). This file does all the thinking — our widgets will just display the results.

Step 0.1 Download the Game Logic File

Download this Dart file and save it as lib/game.dart inside your Birdle project folder. This file contains all the rules for the game — how guesses are checked, what words are valid, and how scoring works.

  1. Make sure your Birdle project is open in VS Code
  2. In the file explorer (left sidebar), right-click on the lib folder
  3. Select "New File"
  4. Name it game.dart
  5. Copy and paste the game logic code into this file
  6. Save the file (Ctrl+S / Cmd+S)
📄 Click to view the full game.dart code (it's long — you just need to copy-paste it)
lib/game.dart
/// Game logic and supporting types for Birdle
library;

import 'dart:collection';
import 'dart:math';

enum HitType { none, hit, partial, miss }

typedef Letter = ({String char, HitType type});

const List<String> legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot'];
const List<String> legalGuesses = ['aback', 'abase', 'abate', 'abbey', 'abbot', 'abhor', 'abide', 'abled', 'abode', 'abort'];
const List<String> allLegalGuesses = [...legalWords, ...legalGuesses];

class Game {
  static const int defaultMaxGuesses = 5;
  
  Game({this.maxGuesses = defaultMaxGuesses, this.seed})
    : _wordToGuess = _generateInitialWord(seed),
      _guesses = List<Word>.filled(maxGuesses, Word.empty());

  final int maxGuesses;
  final int? seed;
  Word _wordToGuess;
  List<Word> _guesses;

  Word get hiddenWord => _wordToGuess;
  UnmodifiableListView<Word> get guesses => UnmodifiableListView(_guesses);
  
  Word get previousGuess {
    final index = _guesses.lastIndexWhere((word) => word.isNotEmpty);
    return index == -1 ? Word.empty() : _guesses[index];
  }

  int get activeIndex => _guesses.indexWhere((word) => word.isEmpty);
  int get guessesRemaining => activeIndex == -1 ? 0 : maxGuesses - activeIndex;
  
  bool get didWin {
    if (_guesses.first.isEmpty) return false;
    for (final letter in previousGuess) {
      if (letter.type != HitType.hit) return false;
    }
    return true;
  }

  bool get didLose => guessesRemaining == 0 && !didWin;

  void resetGame() {
    _wordToGuess = _generateInitialWord(seed);
    _guesses = List<Word>.filled(maxGuesses, Word.empty());
  }

  Word guess(String guess) {
    final result = matchGuessOnly(guess);
    addGuessToList(result);
    return result;
  }

  bool isLegalGuess(String guess) => Word.fromString(guess).isLegalGuess;
  Word matchGuessOnly(String guess) => Word.fromString(guess).evaluateGuess(_wordToGuess);

  void addGuessToList(Word guess) {
    final guessIndex = activeIndex;
    if (guessIndex == -1) throw StateError('No guesses remaining.');
    _guesses[guessIndex] = guess;
  }

  static Word _generateInitialWord(int? seed) =>
      seed == null ? Word.random() : Word.fromSeed(seed);
}

class Word with IterableMixin<Letter> {
  Word(this._letters);
  factory Word.empty() => Word(List<Letter>.filled(5, (char: '', type: HitType.none)));
  factory Word.fromString(String guess) { /* ... */ }
  factory Word.random() { /* ... */ }
  factory Word.fromSeed(int seed) => Word.fromString(legalWords[seed % legalWords.length]);

  final List<Letter> _letters;
  @override Iterator<Letter> get iterator => _letters.iterator;
  @override bool get isEmpty => every((letter) => letter.char.isEmpty);
  @override int get length => _letters.length;
  Letter operator [](int i) => _letters[i];
  @override String toString() => _letters.map((l) => l.char).join().trim();
}

extension WordUtils on Word {
  bool get isLegalGuess => allLegalGuesses.contains(toString());
  Word evaluateGuess(Word hiddenWord) { /* scoring logic */ }
}

Note: The actual file is longer — you can copy the full code from the official Flutter tutorial ↗.

Step 0.2 Import the Game Logic in main.dart

Now tell your main.dart file to use the game logic by adding an import at the top:

lib/main.dart — Add import at the top
import 'package:flutter/material.dart';

import 'game.dart';  // ← Add this line!

This is like telling Flutter: "Hey, I'm going to use things from the game.dart file in this code!"

1 Create Your First Custom Widget — The Tile

Now the fun begins! You're going to create a brand new widget from scratch called Tile. This widget will represent one square in the Birdle game grid.

Step 1.1 The Tile Class — Line by Line

Add this code below the MainApp class in your main.dart file:

🔍 Part 1: The Class Declaration and Constructor

lib/main.dart — Add below MainApp
class Tile extends StatelessWidget {
    const Tile(this.letter, this.hitType, {super.key});

    final String letter;
    final HitType hitType;

    @override
    Widget build(BuildContext context) {
        return Container();
    }
}

What each line means in plain English:

  • class Tile extends StatelessWidget — "I'm creating a new widget type called Tile. It's stateless because once it's drawn, it doesn't change on its own."
  • const Tile(this.letter, this.hitType, ...) — "When someone creates a Tile, they MUST give me a letter (like 'A') and a hitType (like 'correct' or 'wrong'). The this.letter is a shortcut — it automatically saves the value to a property."
  • final String letter; — "I'll remember the letter they gave me in a variable called 'letter'. It's final because it won't change."
  • final HitType hitType; — "I'll also remember what kind of guess result this is (hit, partial, or miss)."
  • build() — "When it's time to draw myself, here's what I look like." Currently it returns an empty Container — we'll fill it in soon!
💡
Why use constructor parameters? Imagine you need 25 tiles for the Birdle game board. Without parameters, you'd need to write 25 different widgets! With parameters, you can write one Tile widget and create 25 instances, each with different letters and colors. That's the power of reusable widgets!

2 Use Your Tile Widget in the App

Let's replace the text in your app with one Tile so you can see it as you build it.

Step 2.1 Display One Tile

In your MainApp.build method, replace the Text widget with a Tile:

lib/main.dart — Updated MainApp.build
class MainApp extends StatelessWidget {
    const MainApp({super.key});

    @override
    Widget build(BuildContext context) {
        return const MaterialApp(
            home: Scaffold(
                body: Center(
                    child: Tile('A', HitType.hit),  // ← NEW: Using our custom widget!
                ),
            ),
        );
    }
}

Hot reload your app. You'll see... nothing? That's because our Tile currently returns an empty Container — a Container with nothing inside is invisible! Let's fix that next.

3 Give Your Tile Size with Container

A Container without a size is invisible. Let's give it width and height so we can see it!

Step 3.1 Set the Size

Update the Tile's build method
@override
Widget build(BuildContext context) {
    return Container(
        width: 60,   // ← 60 pixels wide
        height: 60,  // ← 60 pixels tall (a square!)
    );
}

Hot reload! Now you should see a white square on the screen. It's 60×60 pixels — about the size of a game tile.

📏
What are "pixels" in Flutter? Flutter uses "logical pixels" — they look the same size on any device. So a 60×60 box looks about the same physical size whether you're on a phone, tablet, or desktop. No more worrying about different screen sizes for basic layouts!

4 Style Your Tile with BoxDecoration

A plain white square is boring. Let's add borders and colors using BoxDecoration — the "wrapping paper" for your Container!

Step 4.1 Add a Border

Add decoration to the Container
return Container(
    width: 60,
    height: 60,
    decoration: BoxDecoration(                           // ← NEW!
        border: Border.all(color: Colors.grey.shade300), // ← Light gray border
    ),
);

Hot reload! Now your white square has a light gray border around it — starting to look like a real game tile!

Step 4.2 Add Smart Colors with Switch Expression

Now the cool part — make the tile change color automatically based on the hitType:

  • 🟩 Hit (correct letter, correct position) → Green
  • 🟨 Partial (correct letter, wrong position) → Yellow
  • Miss (wrong letter) → Gray
Add color to BoxDecoration
return Container(
    width: 60,
    height: 60,
    decoration: BoxDecoration(
        border: Border.all(color: Colors.grey.shade300),
        color: switch (hitType) {          // ← Smart color picking!
            HitType.hit     => Colors.green,        // Correct = Green
            HitType.partial => Colors.yellow,       // Partial = Yellow
            HitType.miss    => Colors.grey,          // Miss = Gray
            _               => Colors.white,         // Default = White
        },
    ),
);
🔀
What is a "switch expression"? It's like a smart decision-maker. It looks at the hitType value and says: "If it's a hit → use green. If it's a partial → use yellow. If it's a miss → use gray. For anything else → use white." It's a cleaner way to write multiple if-else statements!

5 Put Content Inside — Center & Text

The tile has a size and color, but it's empty inside. Let's add the letter!

Step 5.1 Add a Child to Container

Add a child property to the Container with a Center and Text widget:

Complete Tile widget
class Tile extends StatelessWidget {
    const Tile(this.letter, this.hitType, {super.key});

    final String letter;
    final HitType hitType;

    @override
    Widget build(BuildContext context) {
        return Container(
            width: 60,
            height: 60,
            decoration: BoxDecoration(
                border: Border.all(color: Colors.grey.shade300),
                color: switch (hitType) {
                    HitType.hit     => Colors.green,
                    HitType.partial => Colors.yellow,
                    HitType.miss    => Colors.grey,
                    _               => Colors.white,
                },
            ),
            child: Center(                              // ← Centers what's inside
                child: Text(                            // ← Shows text
                    letter.toUpperCase(),               // ← Makes 'a' → 'A'
                    style: Theme.of(context).textTheme.titleLarge,  // ← Big, bold text
                ),
            ),
        );
    }
}

What's happening here:

  • Center() — Takes its child and puts it right in the middle (horizontally and vertically).
  • Text(letter.toUpperCase()) — Shows the letter, but makes it UPPERCASE. So 'a' becomes 'A'.
  • Theme.of(context).textTheme.titleLarge — Uses the app's default large title text style (usually bold and around 20-22px).

Step 5.2 Experiment! Change the Colors

Hot reload, and you should see a green square with the letter 'A' in the center! Now try changing the hitType in MainApp:

Try these — hot reload after each!
// Green tile
Tile('A', HitType.hit)
// Gray tile
Tile('A', HitType.miss)
// Yellow tile
Tile('A', HitType.partial)

Each time you change the hitType and hot reload, the tile changes color instantly. One widget, infinite possibilities!

📝 What You Learned Today

You just learned the core skill of Flutter development — creating custom widgets!

Built a Custom StatelessWidget

Created a Tile widget by extending StatelessWidget with a constructor, properties, and a build method.

Made Reusable Widgets

Used constructor parameters (letter and hitType) to make one Tile display different content and colors.

Styled with Container & BoxDecoration

Used Container for size, BoxDecoration for borders and colors, and a switch expression for smart color logic.

Composed Widgets Together

Built the widget tree: Container → Center → Text — nesting widgets inside each other to create the final UI.

🧠 Test Yourself!

Let's see what you remember! Answer these two questions:

Q1 What must every Flutter widget's build method return?

Q2 Which object is used to add decorations like borders and background colors to a Container?

📦 Project 1: Create a Colored Letter Display

Practice what you learned by creating a simple display of colored letter tiles.

Project 1 Beginner

Objective: Display 3 Different Tiles

📋 Requirements:

  1. In MainApp, replace the single Center with a Column containing 3 Tiles:
    • Tile 1: Letter 'F' with HitType.hit (green)
    • Tile 2: Letter 'L' with HitType.partial (yellow)
    • Tile 3: Letter 'Y' with HitType.miss (gray)
  2. Add SizedBox(height: 8) between each Tile to space them apart
  3. Hot reload and verify all three colors appear

🎯 Expected Output:

Three colored squares stacked vertically with the letters F (green), L (yellow), and Y (gray).

💡
Hint: Use mainAxisAlignment: MainAxisAlignment.center on the Column to center the tiles vertically on screen.

📦 Project 2: Create a "Guess This Word" Display

Now build a horizontal row of tiles to display a word — just like the Birdle game board!

Project 2 Beginner

Objective: Display a 5-Letter Word as Tiles

📋 Requirements:

  1. Create a Row with 5 Tiles for the word "FLUTTER" (use just 5 letters, e.g., "FLUTT")
  2. The first letter should be HitType.hit (green)
  3. The second letter should be HitType.partial (yellow)
  4. The remaining three letters should be HitType.miss (gray)
  5. Add SizedBox(width: 4) between each Tile
  6. Wrap the Row in a Column with a title text above it saying "Guess the word:"

🎯 Expected Output:

A title "Guess the word:" with a horizontal row of 5 colored tiles below it, showing F (green), L (yellow), U (gray), T (gray), T (gray).

💡
Hints:
  • Use Row to arrange tiles horizontally
  • Use Column to stack the title text above the row
  • Remember: children: [...] for multiple widgets inside Row/Column
  • Add mainAxisAlignment: MainAxisAlignment.center to center the row

🚀 What's Next?

Excellent work! You now know how to create custom widgets — the most important skill in Flutter. In the next lesson, you'll learn:

  • Layout Widgets — Row, Column, Stack, and more ways to arrange your widgets
  • How to build the full Birdle game grid with 25 tiles
  • Padding, alignment, and spacing techniques
🎉

Lesson Complete!

You built your first custom widget from scratch, learned about Container and BoxDecoration, and created reusable components. You're thinking like a real Flutter developer now!

Click the button above to track your progress!