Building a Reusable Widget Library
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.
- 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,
});
}
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 '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
- 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.keyin every widget constructor.
- Hardcoded colors -- Always use Theme.of(context) or theme extensions. Hardcoded colors break dark mode.
- Missing const constructors -- Add
constto 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
- 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.