Lesson 27 of 51 intermediate

Building Custom Widgets

Extract, Compose, and Reuse

Open interactive version (quiz + challenge)

Real-world analogy

Building custom widgets is like creating your own LEGO pieces. Instead of building the same castle tower from 50 individual bricks every time, you mold those 50 bricks into one custom 'tower piece' that you can stamp out instantly. You can even make it adjustable — different colors, heights, or flags — by adding parameters. Now your castle is built from a few custom pieces instead of thousands of tiny bricks.

What is it?

Building custom widgets means extracting reusable UI components into their own widget classes with parameters for customization, callbacks for events, and widget slots for flexible content. Flutter's composition model encourages building complex UIs from small, focused, reusable pieces rather than monolithic build methods.

Real-world relevance

In team_mvp_kit, custom widgets like PrimaryButton, AppTextField, StatusBadge, and UserAvatar are used across dozens of screens. The project has a widgets/ folder with reusable components that accept parameters and callbacks. This approach ensures visual consistency, reduces code duplication, and makes UI changes propagate everywhere from a single edit.

Key points

Code example

import 'package:flutter/material.dart';

class StatusBadge extends StatelessWidget {
  final String label;
  final StatusType type;

  const StatusBadge({
    super.key,
    required this.label,
    this.type = StatusType.info,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: 12,
        vertical: 6,
      ),
      decoration: BoxDecoration(
        color: _backgroundColor,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _borderColor, width: 1),
      ),
      child: Text(
        label,
        style: TextStyle(
          color: _textColor,
          fontSize: 12,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }

  Color get _backgroundColor {
    switch (type) {
      case StatusType.success:
        return Colors.green.shade50;
      case StatusType.warning:
        return Colors.orange.shade50;
      case StatusType.error:
        return Colors.red.shade50;
      case StatusType.info:
        return Colors.blue.shade50;
    }
  }

  Color get _textColor {
    switch (type) {
      case StatusType.success:
        return Colors.green.shade700;
      case StatusType.warning:
        return Colors.orange.shade700;
      case StatusType.error:
        return Colors.red.shade700;
      case StatusType.info:
        return Colors.blue.shade700;
    }
  }

  Color get _borderColor {
    switch (type) {
      case StatusType.success:
        return Colors.green.shade200;
      case StatusType.warning:
        return Colors.orange.shade200;
      case StatusType.error:
        return Colors.red.shade200;
      case StatusType.info:
        return Colors.blue.shade200;
    }
  }
}

enum StatusType { success, warning, error, info }

// Usage:
// StatusBadge(label: 'Active', type: StatusType.success)
// StatusBadge(label: 'Pending', type: StatusType.warning)
// StatusBadge(label: 'Failed', type: StatusType.error)

Line-by-line walkthrough

  1. 1. Import the Material package
  2. 2.
  3. 3. Define StatusBadge as a StatelessWidget
  4. 4. Required label text to display
  5. 5. StatusType controls the color scheme, defaults to info
  6. 6.
  7. 7. Constructor with required label and optional type
  8. 8.
  9. 9.
  10. 10. Build method returns the styled badge
  11. 11. Container wraps the text with padding and decoration
  12. 12. Symmetric padding: 12px horizontal, 6px vertical
  13. 13.
  14. 14. BoxDecoration for the pill-shaped badge
  15. 15. Background color from the private getter based on type
  16. 16. 16px border radius creates the pill shape
  17. 17. Thin border using the border color getter
  18. 18.
  19. 19. Text displays the label
  20. 20. Styled with color from getter, small font, semibold weight
  21. 21.
  22. 22.
  23. 23.
  24. 24.
  25. 25. Private getter returns background color based on StatusType
  26. 26. Switch on the type enum
  27. 27. Green for success
  28. 28. Orange for warning
  29. 29. Red for error
  30. 30. Blue for info
  31. 31.
  32. 32.
  33. 33. Private getter returns text color (darker shade)
  34. 34. Same switch pattern with shade700 for contrast
  35. 35.
  36. 36.
  37. 37.
  38. 38.
  39. 39.
  40. 40.
  41. 41.
  42. 42.
  43. 43. Private getter returns border color (lighter shade)
  44. 44. Same switch pattern with shade200 for subtle borders
  45. 45.
  46. 46.
  47. 47.
  48. 48.
  49. 49.
  50. 50.
  51. 51.
  52. 52.
  53. 53. Define the StatusType enum with four values
  54. 54.
  55. 55. Usage examples showing different badge configurations
  56. 56. Success badge in green
  57. 57. Warning badge in orange
  58. 58. Error badge in red

Spot the bug

class MyButton extends StatelessWidget {
  final String label;
  final onPressed;

  const MyButton({required this.label, this.onPressed});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: Text(label),
    );
  }
}
Need a hint?
Look at the onPressed parameter declaration. What is missing?
Show answer
The onPressed parameter is missing its type annotation. Without a type, it is implicitly 'dynamic' which defeats Dart's type safety. Fix: declare it as 'final VoidCallback? onPressed;' so Dart knows it is an optional function that takes no arguments and returns void. Also add super.key to the constructor.

Explain like I'm 5

Imagine you love drawing robots. Instead of drawing the same robot head from scratch every time (two circles for eyes, a rectangle for the mouth, antennas), you create a rubber stamp of a robot head. Now you just stamp it whenever you need one! But your stamp is special — you can choose the eye color and whether it smiles or frowns by passing in options. That is a custom widget: a reusable stamp with adjustable settings that you can use anywhere in your app.

Fun fact

Flutter's official style guide recommends that any build method longer than about 30 lines should be split into smaller widgets. The Flutter framework itself is built this way — Scaffold is composed of AppBar, Body, FloatingActionButton, Drawer, and BottomNavigationBar, each of which is its own complex widget!

Hands-on challenge

Create a reusable NotificationCard widget that accepts: an icon Widget slot, a title String, an optional subtitle String, an optional trailing Widget slot, an onTap VoidCallback, and a NotificationType enum (info, success, warning, error) that changes the card's left border color. Then use it three times on a screen with different configurations.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart