Flutter Widgets Fundamentals

Building a Reusable Widget Library

60 min Lesson 18 of 18

Capstone: Your Own Widget Library

In this capstone lesson, you’ll combine everything you’ve learned about Flutter widgets to build a complete, reusable widget library. This is exactly how professional Flutter teams work -- they create a shared component library that ensures visual consistency, reduces code duplication, and accelerates development across the entire application.

We’ll build five production-ready components: AppButton, AppTextField, AppCard, AppAvatar, and AppBadge. Each component will follow Flutter best practices: consistent API design, theme integration, accessibility support, and thorough documentation.

Why Build a Widget Library?
  • Consistency -- Every button, card, and input looks and behaves the same across the entire app.
  • Velocity -- Developers use pre-built components instead of styling from scratch each time.
  • Maintainability -- Change a component in one place, and it updates everywhere.
  • Quality -- Components are tested and documented once, reducing bugs across the app.

Project Structure

A well-organized widget library follows a clear folder structure. Here’s the recommended layout:

Library Folder Structure

lib/
  widgets/
    app_button.dart        // Button variants
    app_text_field.dart    // Text input component
    app_card.dart          // Card variants
    app_avatar.dart        // Avatar component
    app_badge.dart         // Badge/tag component
    widgets.dart           // Barrel export file
  theme/
    app_theme.dart         // Theme definitions
    app_colors.dart        // Custom color extensions
    app_spacing.dart       // Spacing constants
  screens/
    demo_screen.dart       // Component showcase

Component 1: AppButton

A versatile button component that supports multiple variants (filled, outlined, text), sizes, loading states, and icons. This is typically the most-used component in any app.

AppButton Implementation

import 'package:flutter/material.dart';

/// The visual style variant of the button.
enum AppButtonVariant { filled, outlined, text, tonal }

/// The size preset for the button.
enum AppButtonSize { small, medium, large }

/// A customizable button component that integrates with the app theme.
///
/// Supports four variants (filled, outlined, text, tonal),
/// three sizes, loading state, leading/trailing icons,
/// and full-width mode.
///
/// Example:
/// ```dart
/// AppButton(
///   label: 'Save Changes',
///   onPressed: () => save(),
///   variant: AppButtonVariant.filled,
///   size: AppButtonSize.medium,
///   leadingIcon: Icons.save,
/// )
/// ```
class AppButton extends StatelessWidget {
  final String label;
  final VoidCallback? onPressed;
  final AppButtonVariant variant;
  final AppButtonSize size;
  final bool isLoading;
  final bool isFullWidth;
  final IconData? leadingIcon;
  final IconData? trailingIcon;

  const AppButton({
    required this.label,
    this.onPressed,
    this.variant = AppButtonVariant.filled,
    this.size = AppButtonSize.medium,
    this.isLoading = false,
    this.isFullWidth = false,
    this.leadingIcon,
    this.trailingIcon,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final dimensions = _getDimensions();

    // Disable button when loading
    final effectiveOnPressed = isLoading ? null : onPressed;

    // Build the child content
    Widget child = _buildContent(theme, dimensions);

    // Apply full-width constraint if needed
    if (isFullWidth) {
      child = SizedBox(
        width: double.infinity,
        height: dimensions.height,
        child: child,
      );
    }

    return child;
  }

  Widget _buildContent(ThemeData theme, _ButtonDimensions dims) {
    // Build the label with optional icons
    Widget label = Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        if (isLoading) ...[
          SizedBox(
            width: dims.iconSize,
            height: dims.iconSize,
            child: CircularProgressIndicator(
              strokeWidth: 2,
              color: _getProgressColor(theme),
            ),
          ),
          SizedBox(width: dims.iconSpacing),
        ] else if (leadingIcon != null) ...[
          Icon(leadingIcon, size: dims.iconSize),
          SizedBox(width: dims.iconSpacing),
        ],
        Text(this.label),
        if (trailingIcon != null && !isLoading) ...[
          SizedBox(width: dims.iconSpacing),
          Icon(trailingIcon, size: dims.iconSize),
        ],
      ],
    );

    final effectiveOnPressed = isLoading ? null : onPressed;
    final style = _getStyle(theme, dims);

    return switch (variant) {
      AppButtonVariant.filled => FilledButton(
        onPressed: effectiveOnPressed,
        style: style,
        child: label,
      ),
      AppButtonVariant.outlined => OutlinedButton(
        onPressed: effectiveOnPressed,
        style: style,
        child: label,
      ),
      AppButtonVariant.text => TextButton(
        onPressed: effectiveOnPressed,
        style: style,
        child: label,
      ),
      AppButtonVariant.tonal => FilledButton.tonal(
        onPressed: effectiveOnPressed,
        style: style,
        child: label,
      ),
    };
  }

  ButtonStyle _getStyle(ThemeData theme, _ButtonDimensions dims) {
    return ButtonStyle(
      padding: WidgetStatePropertyAll(dims.padding),
      textStyle: WidgetStatePropertyAll(
        TextStyle(fontSize: dims.fontSize, fontWeight: FontWeight.w600),
      ),
      minimumSize: WidgetStatePropertyAll(
        Size(dims.minWidth, dims.height),
      ),
      shape: WidgetStatePropertyAll(
        RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(dims.borderRadius),
        ),
      ),
    );
  }

  Color _getProgressColor(ThemeData theme) {
    return switch (variant) {
      AppButtonVariant.filled => theme.colorScheme.onPrimary,
      AppButtonVariant.outlined => theme.colorScheme.primary,
      AppButtonVariant.text => theme.colorScheme.primary,
      AppButtonVariant.tonal => theme.colorScheme.onSecondaryContainer,
    };
  }

  _ButtonDimensions _getDimensions() {
    return switch (size) {
      AppButtonSize.small => const _ButtonDimensions(
        height: 36,
        minWidth: 64,
        padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        fontSize: 13,
        iconSize: 16,
        iconSpacing: 6,
        borderRadius: 8,
      ),
      AppButtonSize.medium => const _ButtonDimensions(
        height: 44,
        minWidth: 80,
        padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
        fontSize: 14,
        iconSize: 18,
        iconSpacing: 8,
        borderRadius: 10,
      ),
      AppButtonSize.large => const _ButtonDimensions(
        height: 52,
        minWidth: 96,
        padding: EdgeInsets.symmetric(horizontal: 28, vertical: 14),
        fontSize: 16,
        iconSize: 20,
        iconSpacing: 10,
        borderRadius: 12,
      ),
    };
  }
}

class _ButtonDimensions {
  final double height;
  final double minWidth;
  final EdgeInsets padding;
  final double fontSize;
  final double iconSize;
  final double iconSpacing;
  final double borderRadius;

  const _ButtonDimensions({
    required this.height,
    required this.minWidth,
    required this.padding,
    required this.fontSize,
    required this.iconSize,
    required this.iconSpacing,
    required this.borderRadius,
  });
}
API Design Principle: Notice how AppButton uses enums for variants and sizes instead of multiple boolean flags. This makes the API self-documenting and prevents invalid combinations (e.g., you can’t accidentally have both "filled" and "outlined" enabled). The constructor uses named parameters with sensible defaults, so the simplest usage is just AppButton(label: 'Click me', onPressed: ...).

Component 2: AppTextField

A styled text input that standardizes appearance across the app and adds common features like password toggle and character count.

AppTextField Implementation

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

/// A themed text input component with built-in features.
///
/// Provides consistent styling, optional password visibility toggle,
/// character counter, prefix/suffix icons, and validation integration.
///
/// Example:
/// ```dart
/// AppTextField(
///   label: 'Email',
///   hint: 'Enter your email',
///   prefixIcon: Icons.email,
///   keyboardType: TextInputType.emailAddress,
///   validator: (v) => v?.contains('@') == true ? null : 'Invalid',
/// )
/// ```
class AppTextField extends StatefulWidget {
  final String? label;
  final String? hint;
  final String? helperText;
  final TextEditingController? controller;
  final String? Function(String?)? validator;
  final ValueChanged<String>? onChanged;
  final VoidCallback? onEditingComplete;
  final TextInputType? keyboardType;
  final TextInputAction? textInputAction;
  final bool isPassword;
  final bool readOnly;
  final bool enabled;
  final int maxLines;
  final int? maxLength;
  final IconData? prefixIcon;
  final Widget? suffix;
  final List<TextInputFormatter>? inputFormatters;
  final FocusNode? focusNode;
  final bool autofocus;

  const AppTextField({
    this.label,
    this.hint,
    this.helperText,
    this.controller,
    this.validator,
    this.onChanged,
    this.onEditingComplete,
    this.keyboardType,
    this.textInputAction,
    this.isPassword = false,
    this.readOnly = false,
    this.enabled = true,
    this.maxLines = 1,
    this.maxLength,
    this.prefixIcon,
    this.suffix,
    this.inputFormatters,
    this.focusNode,
    this.autofocus = false,
    super.key,
  });

  @override
  State<AppTextField> createState() => _AppTextFieldState();
}

class _AppTextFieldState extends State<AppTextField> {
  bool _obscureText = true;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return TextFormField(
      controller: widget.controller,
      validator: widget.validator,
      onChanged: widget.onChanged,
      onEditingComplete: widget.onEditingComplete,
      keyboardType: widget.keyboardType,
      textInputAction: widget.textInputAction,
      obscureText: widget.isPassword && _obscureText,
      readOnly: widget.readOnly,
      enabled: widget.enabled,
      maxLines: widget.isPassword ? 1 : widget.maxLines,
      maxLength: widget.maxLength,
      inputFormatters: widget.inputFormatters,
      focusNode: widget.focusNode,
      autofocus: widget.autofocus,
      style: theme.textTheme.bodyLarge,
      decoration: InputDecoration(
        labelText: widget.label,
        hintText: widget.hint,
        helperText: widget.helperText,
        helperMaxLines: 2,
        // Prefix icon
        prefixIcon: widget.prefixIcon != null
          ? Icon(widget.prefixIcon)
          : null,
        // Suffix: password toggle or custom widget
        suffixIcon: widget.isPassword
          ? IconButton(
              icon: Icon(
                _obscureText ? Icons.visibility_off : Icons.visibility,
              ),
              onPressed: () =>
                setState(() => _obscureText = !_obscureText),
              tooltip: _obscureText ? 'Show password' : 'Hide password',
            )
          : widget.suffix,
        // Counter text for maxLength
        counterText: widget.maxLength != null ? null : '',
      ),
    );
  }
}

Component 3: AppCard

A flexible card component with multiple layout variants for different content types.

AppCard Implementation

import 'package:flutter/material.dart';

/// Card layout variants.
enum AppCardVariant { standard, outlined, elevated, filled }

/// A themed card component with consistent styling.
///
/// Supports multiple variants, optional header with actions,
/// and integrates with the app theme.
///
/// Example:
/// ```dart
/// AppCard(
///   variant: AppCardVariant.elevated,
///   header: AppCardHeader(
///     title: 'Settings',
///     subtitle: 'Manage your preferences',
///     leading: Icon(Icons.settings),
///     trailing: IconButton(icon: Icon(Icons.more_vert), onPressed: () {}),
///   ),
///   child: SettingsContent(),
///   onTap: () => navigateToSettings(),
/// )
/// ```
class AppCard extends StatelessWidget {
  final Widget child;
  final AppCardVariant variant;
  final AppCardHeader? header;
  final VoidCallback? onTap;
  final VoidCallback? onLongPress;
  final EdgeInsets? padding;
  final EdgeInsets? margin;
  final double? width;
  final double? height;

  const AppCard({
    required this.child,
    this.variant = AppCardVariant.standard,
    this.header,
    this.onTap,
    this.onLongPress,
    this.padding,
    this.margin,
    this.width,
    this.height,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    final cardDecoration = switch (variant) {
      AppCardVariant.standard => BoxDecoration(
        color: colorScheme.surface,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: colorScheme.shadow.withOpacity(0.08),
            blurRadius: 4,
            offset: const Offset(0, 1),
          ),
        ],
      ),
      AppCardVariant.outlined => BoxDecoration(
        color: colorScheme.surface,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: colorScheme.outlineVariant),
      ),
      AppCardVariant.elevated => BoxDecoration(
        color: colorScheme.surface,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: colorScheme.shadow.withOpacity(0.15),
            blurRadius: 12,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      AppCardVariant.filled => BoxDecoration(
        color: colorScheme.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(12),
      ),
    };

    Widget content = Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        if (header != null) ...[
          header!,
          const Divider(height: 1),
        ],
        Padding(
          padding: padding ?? const EdgeInsets.all(16),
          child: child,
        ),
      ],
    );

    Widget card = Container(
      width: width,
      height: height,
      margin: margin ?? const EdgeInsets.symmetric(vertical: 4),
      decoration: cardDecoration,
      clipBehavior: Clip.antiAlias,
      child: content,
    );

    // Wrap with InkWell if tappable
    if (onTap != null || onLongPress != null) {
      card = Material(
        color: Colors.transparent,
        child: InkWell(
          onTap: onTap,
          onLongPress: onLongPress,
          borderRadius: BorderRadius.circular(12),
          child: card,
        ),
      );
    }

    return card;
  }
}

/// Header section for AppCard.
class AppCardHeader extends StatelessWidget {
  final String title;
  final String? subtitle;
  final Widget? leading;
  final Widget? trailing;

  const AppCardHeader({
    required this.title,
    this.subtitle,
    this.leading,
    this.trailing,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
      child: Row(
        children: [
          if (leading != null) ...[
            leading!,
            const SizedBox(width: 12),
          ],
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(title, style: theme.textTheme.titleMedium),
                if (subtitle != null)
                  Text(
                    subtitle!,
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
              ],
            ),
          ),
          if (trailing != null) trailing!,
        ],
      ),
    );
  }
}

Component 4: AppAvatar

An avatar component that handles images, initials fallback, online status indicators, and different sizes.

AppAvatar Implementation

import 'package:flutter/material.dart';

/// Size presets for the avatar.
enum AppAvatarSize { xs, sm, md, lg, xl }

/// A themed avatar component with image, initials fallback,
/// and optional status indicator.
///
/// Automatically generates initials from the name if no image is provided,
/// and assigns a consistent color based on the name hash.
///
/// Example:
/// ```dart
/// AppAvatar(
///   imageUrl: user.avatarUrl,
///   name: user.fullName,
///   size: AppAvatarSize.md,
///   showStatus: true,
///   isOnline: user.isOnline,
/// )
/// ```
class AppAvatar extends StatelessWidget {
  final String? imageUrl;
  final String name;
  final AppAvatarSize size;
  final bool showStatus;
  final bool isOnline;
  final VoidCallback? onTap;

  const AppAvatar({
    required this.name,
    this.imageUrl,
    this.size = AppAvatarSize.md,
    this.showStatus = false,
    this.isOnline = false,
    this.onTap,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final dims = _getDimensions();

    Widget avatar = Container(
      width: dims.size,
      height: dims.size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: _getBackgroundColor(theme),
      ),
      clipBehavior: Clip.antiAlias,
      child: imageUrl != null && imageUrl!.isNotEmpty
        ? Image.network(
            imageUrl!,
            fit: BoxFit.cover,
            width: dims.size,
            height: dims.size,
            errorBuilder: (_, __, ___) => _buildInitials(theme, dims),
          )
        : _buildInitials(theme, dims),
    );

    // Add status indicator
    if (showStatus) {
      avatar = Stack(
        children: [
          avatar,
          Positioned(
            right: 0,
            bottom: 0,
            child: Container(
              width: dims.statusSize,
              height: dims.statusSize,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: isOnline
                  ? const Color(0xFF4CAF50)
                  : theme.colorScheme.outlineVariant,
                border: Border.all(
                  color: theme.colorScheme.surface,
                  width: dims.statusBorder,
                ),
              ),
            ),
          ),
        ],
      );
    }

    // Wrap with GestureDetector if tappable
    if (onTap != null) {
      avatar = GestureDetector(
        onTap: onTap,
        child: avatar,
      );
    }

    return avatar;
  }

  Widget _buildInitials(ThemeData theme, _AvatarDimensions dims) {
    final initials = _getInitials();
    return Center(
      child: Text(
        initials,
        style: TextStyle(
          color: Colors.white,
          fontSize: dims.fontSize,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }

  String _getInitials() {
    final parts = name.trim().split(RegExp(r'\s+'));
    if (parts.length >= 2) {
      return '\${parts.first[0]}\${parts.last[0]}'.toUpperCase();
    }
    return parts.first.isNotEmpty
      ? parts.first[0].toUpperCase()
      : '?';
  }

  Color _getBackgroundColor(ThemeData theme) {
    // Generate consistent color from name
    final colors = [
      const Color(0xFF1E88E5),
      const Color(0xFF43A047),
      const Color(0xFFE53935),
      const Color(0xFF8E24AA),
      const Color(0xFFFF6F00),
      const Color(0xFF00ACC1),
      const Color(0xFF5E35B1),
      const Color(0xFFD81B60),
    ];
    final index = name.hashCode.abs() % colors.length;
    return colors[index];
  }

  _AvatarDimensions _getDimensions() {
    return switch (size) {
      AppAvatarSize.xs => const _AvatarDimensions(
        size: 24, fontSize: 10, statusSize: 8, statusBorder: 1.5),
      AppAvatarSize.sm => const _AvatarDimensions(
        size: 32, fontSize: 12, statusSize: 10, statusBorder: 2),
      AppAvatarSize.md => const _AvatarDimensions(
        size: 44, fontSize: 16, statusSize: 12, statusBorder: 2),
      AppAvatarSize.lg => const _AvatarDimensions(
        size: 64, fontSize: 22, statusSize: 14, statusBorder: 2.5),
      AppAvatarSize.xl => const _AvatarDimensions(
        size: 96, fontSize: 32, statusSize: 18, statusBorder: 3),
    };
  }
}

class _AvatarDimensions {
  final double size;
  final double fontSize;
  final double statusSize;
  final double statusBorder;

  const _AvatarDimensions({
    required this.size,
    required this.fontSize,
    required this.statusSize,
    required this.statusBorder,
  });
}

Component 5: AppBadge

A badge/tag component for labels, statuses, categories, and notification counts.

AppBadge Implementation

import 'package:flutter/material.dart';

/// Badge color variants.
enum AppBadgeVariant { primary, secondary, success, warning, error, info }

/// Badge size presets.
enum AppBadgeSize { small, medium, large }

/// A themed badge/tag component for labels and status indicators.
///
/// Supports multiple color variants, optional icons, and
/// a notification dot mode.
///
/// Example:
/// ```dart
/// AppBadge(
///   label: 'Published',
///   variant: AppBadgeVariant.success,
///   icon: Icons.check_circle,
/// )
///
/// // Notification count badge
/// AppBadge.count(count: 5)
/// ```
class AppBadge extends StatelessWidget {
  final String label;
  final AppBadgeVariant variant;
  final AppBadgeSize size;
  final IconData? icon;
  final bool isDismissible;
  final VoidCallback? onDismiss;

  const AppBadge({
    required this.label,
    this.variant = AppBadgeVariant.primary,
    this.size = AppBadgeSize.medium,
    this.icon,
    this.isDismissible = false,
    this.onDismiss,
    super.key,
  });

  /// Creates a compact count badge (e.g., notification count).
  factory AppBadge.count({
    required int count,
    AppBadgeVariant variant = AppBadgeVariant.error,
    Key? key,
  }) {
    return AppBadge(
      key: key,
      label: count > 99 ? '99+' : count.toString(),
      variant: variant,
      size: AppBadgeSize.small,
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final dims = _getDimensions();
    final colors = _getColors(theme);

    return Container(
      padding: dims.padding,
      decoration: BoxDecoration(
        color: colors.background,
        borderRadius: BorderRadius.circular(dims.borderRadius),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (icon != null) ...[
            Icon(icon, size: dims.iconSize, color: colors.foreground),
            SizedBox(width: dims.iconSpacing),
          ],
          Text(
            label,
            style: TextStyle(
              color: colors.foreground,
              fontSize: dims.fontSize,
              fontWeight: FontWeight.w600,
              height: 1.2,
            ),
          ),
          if (isDismissible) ...[
            SizedBox(width: dims.iconSpacing),
            GestureDetector(
              onTap: onDismiss,
              child: Icon(
                Icons.close,
                size: dims.iconSize,
                color: colors.foreground,
              ),
            ),
          ],
        ],
      ),
    );
  }

  _BadgeColors _getColors(ThemeData theme) {
    final scheme = theme.colorScheme;
    return switch (variant) {
      AppBadgeVariant.primary => _BadgeColors(
        scheme.primaryContainer, scheme.onPrimaryContainer),
      AppBadgeVariant.secondary => _BadgeColors(
        scheme.secondaryContainer, scheme.onSecondaryContainer),
      AppBadgeVariant.success => _BadgeColors(
        const Color(0xFFE8F5E9), const Color(0xFF2E7D32)),
      AppBadgeVariant.warning => _BadgeColors(
        const Color(0xFFFFF3E0), const Color(0xFFE65100)),
      AppBadgeVariant.error => _BadgeColors(
        scheme.errorContainer, scheme.onErrorContainer),
      AppBadgeVariant.info => _BadgeColors(
        const Color(0xFFE3F2FD), const Color(0xFF1565C0)),
    };
  }

  _BadgeDimensions _getDimensions() {
    return switch (size) {
      AppBadgeSize.small => const _BadgeDimensions(
        padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
        fontSize: 11,
        iconSize: 12,
        iconSpacing: 3,
        borderRadius: 6,
      ),
      AppBadgeSize.medium => const _BadgeDimensions(
        padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
        fontSize: 12,
        iconSize: 14,
        iconSpacing: 4,
        borderRadius: 8,
      ),
      AppBadgeSize.large => const _BadgeDimensions(
        padding: EdgeInsets.symmetric(horizontal: 14, vertical: 6),
        fontSize: 14,
        iconSize: 16,
        iconSpacing: 6,
        borderRadius: 10,
      ),
    };
  }
}

class _BadgeColors {
  final Color background;
  final Color foreground;
  const _BadgeColors(this.background, this.foreground);
}

class _BadgeDimensions {
  final EdgeInsets padding;
  final double fontSize;
  final double iconSize;
  final double iconSpacing;
  final double borderRadius;

  const _BadgeDimensions({
    required this.padding,
    required this.fontSize,
    required this.iconSize,
    required this.iconSpacing,
    required this.borderRadius,
  });
}

The Barrel Export File

A barrel file provides a single import for all widgets. This is essential for a clean API surface.

widgets.dart -- Barrel Export

/// App Widget Library
///
/// A collection of reusable, themed UI components.
///
/// Usage:
/// ```dart
/// import 'package:my_app/widgets/widgets.dart';
/// ```
library widgets;

export 'app_button.dart';
export 'app_text_field.dart';
export 'app_card.dart';
export 'app_avatar.dart';
export 'app_badge.dart';
Import Benefit: With the barrel file, consumers only need one import: import 'package:my_app/widgets/widgets.dart'; -- this gives access to all components, all enums, and all related types. Without it, each component would require a separate import statement.

Theme Integration

For the badge component, the hardcoded success/warning/info colors should ideally come from a theme extension so they adapt to dark mode. Here’s how to wire the library to the theme:

Connecting Components to Theme Extensions

// lib/theme/app_colors.dart
class AppSemanticColors extends ThemeExtension<AppSemanticColors> {
  final Color successBackground;
  final Color successForeground;
  final Color warningBackground;
  final Color warningForeground;
  final Color infoBackground;
  final Color infoForeground;

  const AppSemanticColors({
    required this.successBackground,
    required this.successForeground,
    required this.warningBackground,
    required this.warningForeground,
    required this.infoBackground,
    required this.infoForeground,
  });

  static const light = AppSemanticColors(
    successBackground: Color(0xFFE8F5E9),
    successForeground: Color(0xFF2E7D32),
    warningBackground: Color(0xFFFFF3E0),
    warningForeground: Color(0xFFE65100),
    infoBackground: Color(0xFFE3F2FD),
    infoForeground: Color(0xFF1565C0),
  );

  static const dark = AppSemanticColors(
    successBackground: Color(0xFF1B5E20),
    successForeground: Color(0xFFA5D6A7),
    warningBackground: Color(0xFFBF360C),
    warningForeground: Color(0xFFFFCC80),
    infoBackground: Color(0xFF0D47A1),
    infoForeground: Color(0xFF90CAF9),
  );

  @override
  AppSemanticColors copyWith({
    Color? successBackground,
    Color? successForeground,
    Color? warningBackground,
    Color? warningForeground,
    Color? infoBackground,
    Color? infoForeground,
  }) {
    return AppSemanticColors(
      successBackground: successBackground ?? this.successBackground,
      successForeground: successForeground ?? this.successForeground,
      warningBackground: warningBackground ?? this.warningBackground,
      warningForeground: warningForeground ?? this.warningForeground,
      infoBackground: infoBackground ?? this.infoBackground,
      infoForeground: infoForeground ?? this.infoForeground,
    );
  }

  @override
  AppSemanticColors lerp(AppSemanticColors? other, double t) {
    if (other is! AppSemanticColors) return this;
    return AppSemanticColors(
      successBackground: Color.lerp(successBackground, other.successBackground, t)!,
      successForeground: Color.lerp(successForeground, other.successForeground, t)!,
      warningBackground: Color.lerp(warningBackground, other.warningBackground, t)!,
      warningForeground: Color.lerp(warningForeground, other.warningForeground, t)!,
      infoBackground: Color.lerp(infoBackground, other.infoBackground, t)!,
      infoForeground: Color.lerp(infoForeground, other.infoForeground, t)!,
    );
  }
}

// Updated AppBadge._getColors using theme extension:
_BadgeColors _getColors(ThemeData theme) {
  final scheme = theme.colorScheme;
  final semantic = theme.extension<AppSemanticColors>();

  return switch (variant) {
    AppBadgeVariant.primary => _BadgeColors(
      scheme.primaryContainer, scheme.onPrimaryContainer),
    AppBadgeVariant.secondary => _BadgeColors(
      scheme.secondaryContainer, scheme.onSecondaryContainer),
    AppBadgeVariant.success => _BadgeColors(
      semantic?.successBackground ?? const Color(0xFFE8F5E9),
      semantic?.successForeground ?? const Color(0xFF2E7D32)),
    AppBadgeVariant.warning => _BadgeColors(
      semantic?.warningBackground ?? const Color(0xFFFFF3E0),
      semantic?.warningForeground ?? const Color(0xFFE65100)),
    AppBadgeVariant.error => _BadgeColors(
      scheme.errorContainer, scheme.onErrorContainer),
    AppBadgeVariant.info => _BadgeColors(
      semantic?.infoBackground ?? const Color(0xFFE3F2FD),
      semantic?.infoForeground ?? const Color(0xFF1565C0)),
  };
}

Demo App: Showcasing the Library

A demo screen is essential for both development and documentation. It showcases every component variant so developers can see what’s available:

Component Demo Screen

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

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

  @override
  State<WidgetDemoScreen> createState() => _WidgetDemoScreenState();
}

class _WidgetDemoScreenState extends State<WidgetDemoScreen> {
  bool _isLoading = false;
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Widget Library Demo')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // === BUTTONS ===
            _sectionTitle(context, 'Buttons'),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                AppButton(
                  label: 'Filled',
                  onPressed: () {},
                  variant: AppButtonVariant.filled,
                ),
                AppButton(
                  label: 'Outlined',
                  onPressed: () {},
                  variant: AppButtonVariant.outlined,
                ),
                AppButton(
                  label: 'Text',
                  onPressed: () {},
                  variant: AppButtonVariant.text,
                ),
                AppButton(
                  label: 'Tonal',
                  onPressed: () {},
                  variant: AppButtonVariant.tonal,
                ),
              ],
            ),
            const SizedBox(height: 12),

            // Button sizes
            Wrap(
              spacing: 8,
              runSpacing: 8,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: [
                AppButton(
                  label: 'Small',
                  onPressed: () {},
                  size: AppButtonSize.small,
                ),
                AppButton(
                  label: 'Medium',
                  onPressed: () {},
                  size: AppButtonSize.medium,
                ),
                AppButton(
                  label: 'Large',
                  onPressed: () {},
                  size: AppButtonSize.large,
                ),
              ],
            ),
            const SizedBox(height: 12),

            // Button with icons and loading
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                AppButton(
                  label: 'Save',
                  onPressed: () {},
                  leadingIcon: Icons.save,
                ),
                AppButton(
                  label: 'Next',
                  onPressed: () {},
                  trailingIcon: Icons.arrow_forward,
                ),
                AppButton(
                  label: 'Loading...',
                  onPressed: () {},
                  isLoading: true,
                ),
                AppButton(
                  label: 'Disabled',
                  onPressed: null,
                ),
              ],
            ),
            const SizedBox(height: 8),
            AppButton(
              label: 'Full Width Button',
              onPressed: () {},
              isFullWidth: true,
              size: AppButtonSize.large,
            ),

            const SizedBox(height: 32),

            // === TEXT FIELDS ===
            _sectionTitle(context, 'Text Fields'),
            const SizedBox(height: 8),
            Form(
              key: _formKey,
              child: Column(
                children: [
                  AppTextField(
                    label: 'Email',
                    hint: 'you@example.com',
                    prefixIcon: Icons.email,
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    validator: (v) =>
                      v?.contains('@') == true ? null : 'Invalid email',
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    label: 'Password',
                    hint: 'Enter your password',
                    prefixIcon: Icons.lock,
                    controller: _passwordController,
                    isPassword: true,
                    validator: (v) =>
                      v != null && v.length >= 8 ? null : 'Min 8 characters',
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    label: 'Bio',
                    hint: 'Tell us about yourself...',
                    maxLines: 3,
                    maxLength: 200,
                  ),
                  const SizedBox(height: 12),
                  AppButton(
                    label: 'Submit',
                    isFullWidth: true,
                    onPressed: () {
                      if (_formKey.currentState!.validate()) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('Form valid!')),
                        );
                      }
                    },
                  ),
                ],
              ),
            ),

            const SizedBox(height: 32),

            // === CARDS ===
            _sectionTitle(context, 'Cards'),
            const SizedBox(height: 8),
            AppCard(
              variant: AppCardVariant.standard,
              header: const AppCardHeader(
                title: 'Standard Card',
                subtitle: 'Default card style',
                leading: Icon(Icons.credit_card),
              ),
              child: const Text('Card body content goes here.'),
            ),
            AppCard(
              variant: AppCardVariant.outlined,
              header: const AppCardHeader(
                title: 'Outlined Card',
                subtitle: 'With border, no shadow',
              ),
              onTap: () {},
              child: const Text('This card is tappable.'),
            ),
            AppCard(
              variant: AppCardVariant.elevated,
              child: const Text('Elevated card without header.'),
            ),

            const SizedBox(height: 32),

            // === AVATARS ===
            _sectionTitle(context, 'Avatars'),
            const SizedBox(height: 8),
            Wrap(
              spacing: 12,
              runSpacing: 8,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: [
                const AppAvatar(name: 'John Doe', size: AppAvatarSize.xs),
                const AppAvatar(name: 'Jane Smith', size: AppAvatarSize.sm),
                const AppAvatar(name: 'Ahmad Ali', size: AppAvatarSize.md),
                const AppAvatar(
                  name: 'Sara Johnson',
                  size: AppAvatarSize.lg,
                  showStatus: true,
                  isOnline: true,
                ),
                const AppAvatar(
                  name: 'Mike Brown',
                  size: AppAvatarSize.xl,
                  showStatus: true,
                  isOnline: false,
                ),
              ],
            ),

            const SizedBox(height: 32),

            // === BADGES ===
            _sectionTitle(context, 'Badges'),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                const AppBadge(
                  label: 'Primary',
                  variant: AppBadgeVariant.primary,
                ),
                const AppBadge(
                  label: 'Published',
                  variant: AppBadgeVariant.success,
                  icon: Icons.check_circle,
                ),
                const AppBadge(
                  label: 'Warning',
                  variant: AppBadgeVariant.warning,
                  icon: Icons.warning,
                ),
                const AppBadge(
                  label: 'Error',
                  variant: AppBadgeVariant.error,
                ),
                AppBadge(
                  label: 'Removable',
                  variant: AppBadgeVariant.info,
                  isDismissible: true,
                  onDismiss: () {},
                ),
                AppBadge.count(count: 5),
                AppBadge.count(count: 150),
              ],
            ),

            const SizedBox(height: 48),
          ],
        ),
      ),
    );
  }

  Widget _sectionTitle(BuildContext context, String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: Text(
        title,
        style: Theme.of(context).textTheme.headlineSmall,
      ),
    );
  }
}

Best Practices for Widget Libraries

Consistent API Design Rules:
  • Use enums for variant types and sizes -- never multiple booleans.
  • Make the most common configuration the default so simple usage requires fewer parameters.
  • Use required only for parameters the component truly cannot function without.
  • Prefer composition over inheritance -- widgets should accept child widgets, not extend each other.
  • Include a Key? key parameter via super.key in every widget constructor.
Common Mistakes to Avoid:
  • Hardcoded colors -- Always use Theme.of(context) or theme extensions. Hardcoded colors break dark mode.
  • Missing const constructors -- Add const to constructors when all fields are final and have no mutable defaults.
  • Ignoring accessibility -- Include semanticLabel, tooltip, and sufficient contrast ratios.
  • Over-parameterization -- Don’t expose every internal detail. Start minimal, add parameters as real use cases demand.
  • Forgetting dispose -- StatefulWidgets that create controllers or focus nodes must dispose them.

Documentation with Comments

Every public component should have Dart doc comments (///) that include a description, parameter explanations, and a usage example:

Documentation Pattern

/// A brief description of what the widget does.
///
/// More detailed explanation of behavior, variants,
/// and integration points.
///
/// ## Parameters
/// * [label] -- The text displayed on the button.
/// * [variant] -- The visual style (filled, outlined, text, tonal).
/// * [size] -- The size preset (small, medium, large).
/// * [isLoading] -- Shows a spinner and disables interaction.
///
/// ## Example
/// ```dart
/// AppButton(
///   label: 'Submit',
///   onPressed: () => handleSubmit(),
///   variant: AppButtonVariant.filled,
///   leadingIcon: Icons.check,
/// )
/// ```
///
/// See also:
/// * [AppButtonVariant] for available visual styles.
/// * [AppButtonSize] for size presets.
class AppButton extends StatelessWidget { ... }

Summary

Capstone Takeaways:
  • A widget library provides consistency, velocity, maintainability, and quality across your app.
  • Use enums for variant/size options and named parameters with defaults for clean APIs.
  • Every component should integrate with Theme.of(context) and support dark mode via theme extensions.
  • Create a barrel export file (widgets.dart) for single-import access to all components.
  • Components should support loading states, disabled states, and accessibility out of the box.
  • Build a demo screen that showcases every variant -- it serves as both documentation and a visual regression test.
  • Use composition over inheritance: AppCard accepts a child widget, not a subclass.
  • Document every public class and enum with /// doc comments including usage examples.
  • Start with a minimal API and expand as real use cases emerge -- avoid premature abstraction.
  • Use private helper classes (like _ButtonDimensions) to keep dimension logic organized without polluting the public API.

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.