This commit is contained in:
2025-09-26 18:48:14 +07:00
parent 382a0e7909
commit 30ed6b39b5
85 changed files with 20722 additions and 112 deletions

View File

@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
/// Customizable button component with multiple variants following Material 3 design
///
/// Supports filled, outlined, text, and icon variants with consistent theming
/// and accessibility features.
class AppButton extends StatelessWidget {
/// Creates an app button with the specified variant and configuration
const AppButton({
super.key,
required this.text,
required this.onPressed,
this.variant = AppButtonVariant.filled,
this.size = AppButtonSize.medium,
this.icon,
this.isLoading = false,
this.isFullWidth = false,
this.backgroundColor,
this.foregroundColor,
this.borderColor,
this.elevation,
this.semanticLabel,
});
/// The text displayed on the button
final String text;
/// Called when the button is pressed
final VoidCallback? onPressed;
/// The visual variant of the button
final AppButtonVariant variant;
/// The size of the button
final AppButtonSize size;
/// Optional icon to display alongside text
final IconData? icon;
/// Whether to show a loading indicator
final bool isLoading;
/// Whether the button should take full width
final bool isFullWidth;
/// Custom background color override
final Color? backgroundColor;
/// Custom foreground color override
final Color? foregroundColor;
/// Custom border color override (for outlined variant)
final Color? borderColor;
/// Custom elevation override
final double? elevation;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Get button configuration based on size
final buttonHeight = _getButtonHeight();
final buttonPadding = _getButtonPadding();
final textStyle = _getTextStyle();
final iconSize = _getIconSize();
// Create button style
final buttonStyle = _createButtonStyle(
theme: theme,
height: buttonHeight,
padding: buttonPadding,
);
// Handle loading state
if (isLoading) {
return _buildLoadingButton(
context: context,
style: buttonStyle,
height: buttonHeight,
);
}
// Build appropriate button variant
Widget button = switch (variant) {
AppButtonVariant.filled => _buildFilledButton(
context: context,
style: buttonStyle,
textStyle: textStyle,
iconSize: iconSize,
),
AppButtonVariant.outlined => _buildOutlinedButton(
context: context,
style: buttonStyle,
textStyle: textStyle,
iconSize: iconSize,
),
AppButtonVariant.text => _buildTextButton(
context: context,
style: buttonStyle,
textStyle: textStyle,
iconSize: iconSize,
),
AppButtonVariant.icon => _buildIconButton(
context: context,
iconSize: iconSize,
),
};
// Apply full width if needed
if (isFullWidth && variant != AppButtonVariant.icon) {
button = SizedBox(
width: double.infinity,
child: button,
);
}
// Add semantic label for accessibility
if (semanticLabel != null) {
button = Semantics(
label: semanticLabel,
child: button,
);
}
return button;
}
/// Build filled button variant
Widget _buildFilledButton({
required BuildContext context,
required ButtonStyle style,
required TextStyle textStyle,
required double iconSize,
}) {
if (icon != null) {
return FilledButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: iconSize),
label: Text(text, style: textStyle),
style: style,
);
}
return FilledButton(
onPressed: onPressed,
style: style,
child: Text(text, style: textStyle),
);
}
/// Build outlined button variant
Widget _buildOutlinedButton({
required BuildContext context,
required ButtonStyle style,
required TextStyle textStyle,
required double iconSize,
}) {
if (icon != null) {
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: iconSize),
label: Text(text, style: textStyle),
style: style,
);
}
return OutlinedButton(
onPressed: onPressed,
style: style,
child: Text(text, style: textStyle),
);
}
/// Build text button variant
Widget _buildTextButton({
required BuildContext context,
required ButtonStyle style,
required TextStyle textStyle,
required double iconSize,
}) {
if (icon != null) {
return TextButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: iconSize),
label: Text(text, style: textStyle),
style: style,
);
}
return TextButton(
onPressed: onPressed,
style: style,
child: Text(text, style: textStyle),
);
}
/// Build icon button variant
Widget _buildIconButton({
required BuildContext context,
required double iconSize,
}) {
if (icon == null) {
throw ArgumentError('Icon button requires an icon');
}
final theme = Theme.of(context);
return IconButton(
onPressed: onPressed,
icon: Icon(icon, size: iconSize),
style: IconButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor ?? theme.colorScheme.primary,
minimumSize: Size(_getButtonHeight(), _getButtonHeight()),
maximumSize: Size(_getButtonHeight(), _getButtonHeight()),
),
tooltip: text,
);
}
/// Build loading button state
Widget _buildLoadingButton({
required BuildContext context,
required ButtonStyle style,
required double height,
}) {
final theme = Theme.of(context);
return FilledButton(
onPressed: null,
style: style,
child: SizedBox(
height: height * 0.5,
width: height * 0.5,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
foregroundColor ?? theme.colorScheme.onPrimary,
),
),
),
);
}
/// Create button style based on variant and customizations
ButtonStyle _createButtonStyle({
required ThemeData theme,
required double height,
required EdgeInsets padding,
}) {
return ButtonStyle(
minimumSize: WidgetStateProperty.all(Size(0, height)),
padding: WidgetStateProperty.all(padding),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: AppSpacing.buttonRadius,
),
),
backgroundColor: backgroundColor != null
? WidgetStateProperty.all(backgroundColor)
: null,
foregroundColor: foregroundColor != null
? WidgetStateProperty.all(foregroundColor)
: null,
side: borderColor != null && variant == AppButtonVariant.outlined
? WidgetStateProperty.all(BorderSide(color: borderColor!))
: null,
elevation: elevation != null
? WidgetStateProperty.all(elevation)
: null,
);
}
/// Get button height based on size
double _getButtonHeight() {
return switch (size) {
AppButtonSize.small => AppSpacing.buttonHeightSmall,
AppButtonSize.medium => AppSpacing.buttonHeight,
AppButtonSize.large => AppSpacing.buttonHeightLarge,
};
}
/// Get button padding based on size
EdgeInsets _getButtonPadding() {
return switch (size) {
AppButtonSize.small => const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
AppButtonSize.medium => const EdgeInsets.symmetric(
horizontal: AppSpacing.buttonPaddingHorizontal,
vertical: AppSpacing.buttonPaddingVertical,
),
AppButtonSize.large => const EdgeInsets.symmetric(
horizontal: AppSpacing.xxxl,
vertical: AppSpacing.lg,
),
};
}
/// Get text style based on size
TextStyle _getTextStyle() {
return switch (size) {
AppButtonSize.small => AppTypography.labelMedium,
AppButtonSize.medium => AppTypography.buttonText,
AppButtonSize.large => AppTypography.labelLarge,
};
}
/// Get icon size based on button size
double _getIconSize() {
return switch (size) {
AppButtonSize.small => AppSpacing.iconSM,
AppButtonSize.medium => AppSpacing.iconMD,
AppButtonSize.large => AppSpacing.iconLG,
};
}
}
/// Available button variants
enum AppButtonVariant {
/// Filled button with background color (primary action)
filled,
/// Outlined button with border (secondary action)
outlined,
/// Text button without background (tertiary action)
text,
/// Icon-only button
icon,
}
/// Available button sizes
enum AppButtonSize {
/// Small button (32dp height)
small,
/// Medium button (40dp height) - default
medium,
/// Large button (56dp height)
large,
}

View File

@@ -0,0 +1,538 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
/// Reusable card component with Material 3 styling and customization options
///
/// Provides a consistent card design with support for different variants,
/// elevation levels, and content layouts.
class AppCard extends StatelessWidget {
/// Creates a card with the specified content and styling options
const AppCard({
super.key,
required this.child,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.none,
this.onTap,
this.semanticLabel,
}) : title = null,
subtitle = null,
leading = null,
trailing = null,
actions = null,
mediaWidget = null;
/// Creates a card with a title, subtitle, and optional actions
const AppCard.titled({
super.key,
required this.title,
this.subtitle,
this.child,
this.leading,
this.trailing,
this.actions,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.none,
this.onTap,
this.semanticLabel,
}) : mediaWidget = null;
/// Creates a card optimized for displaying media content
const AppCard.media({
super.key,
required this.mediaWidget,
this.title,
this.subtitle,
this.child,
this.actions,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.antiAlias,
this.onTap,
this.semanticLabel,
}) : leading = null,
trailing = null;
/// The content to display inside the card
final Widget? child;
/// Title for titled cards
final String? title;
/// Subtitle for titled cards
final String? subtitle;
/// Leading widget for titled cards (typically an icon or avatar)
final Widget? leading;
/// Trailing widget for titled cards (typically an icon or button)
final Widget? trailing;
/// Action buttons displayed at the bottom of the card
final List<Widget>? actions;
/// Media widget for media cards (typically an image or video)
final Widget? mediaWidget;
/// The visual variant of the card
final AppCardVariant variant;
/// Internal padding of the card content
final EdgeInsets? padding;
/// External margin around the card
final EdgeInsets? margin;
/// Background color override
final Color? backgroundColor;
/// Shadow color override
final Color? shadowColor;
/// Surface tint color for Material 3
final Color? surfaceTintColor;
/// Elevation level override
final double? elevation;
/// Shape override
final ShapeBorder? shape;
/// Clipping behavior for card content
final Clip clipBehavior;
/// Called when the card is tapped
final VoidCallback? onTap;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cardTheme = theme.cardTheme;
// Determine card content based on constructor used
Widget content;
if (title != null) {
content = _buildTitledContent(context);
} else if (mediaWidget != null) {
content = _buildMediaContent(context);
} else if (child != null) {
content = child!;
} else {
content = const SizedBox.shrink();
}
// Apply padding if specified
if (padding != null) {
content = Padding(
padding: padding!,
child: content,
);
} else if (title != null || mediaWidget != null) {
// Apply default padding for titled/media cards
content = Padding(
padding: const EdgeInsets.all(AppSpacing.cardPadding),
child: content,
);
}
// Create the card widget
Widget card = Card(
elevation: _getElevation(theme),
color: backgroundColor ?? cardTheme.color,
shadowColor: shadowColor ?? cardTheme.shadowColor,
surfaceTintColor: surfaceTintColor ?? cardTheme.surfaceTintColor,
shape: shape ?? cardTheme.shape ??
RoundedRectangleBorder(borderRadius: AppSpacing.cardRadius),
clipBehavior: clipBehavior,
margin: margin ?? cardTheme.margin ?? const EdgeInsets.all(AppSpacing.cardMargin),
child: content,
);
// Make card tappable if onTap is provided
if (onTap != null) {
card = InkWell(
onTap: onTap,
borderRadius: shape is RoundedRectangleBorder
? (shape as RoundedRectangleBorder).borderRadius as BorderRadius?
: AppSpacing.cardRadius,
child: card,
);
}
// Add semantic label for accessibility
if (semanticLabel != null) {
card = Semantics(
label: semanticLabel,
child: card,
);
}
return card;
}
/// Build content for titled cards
Widget _buildTitledContent(BuildContext context) {
final theme = Theme.of(context);
final titleStyle = theme.textTheme.titleMedium;
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final List<Widget> children = [];
// Add header with title, subtitle, leading, and trailing
if (title != null || leading != null || trailing != null) {
children.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[
leading!,
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null)
Text(
title!,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: subtitleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (trailing != null) ...[
AppSpacing.horizontalSpaceXS,
trailing!,
],
],
),
);
}
// Add main content
if (child != null) {
if (children.isNotEmpty) {
children.add(AppSpacing.verticalSpaceLG);
}
children.add(child!);
}
// Add actions
if (actions != null && actions!.isNotEmpty) {
if (children.isNotEmpty) {
children.add(AppSpacing.verticalSpaceLG);
}
children.add(
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
.take(actions!.length * 2 - 1)
.toList(),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
/// Build content for media cards
Widget _buildMediaContent(BuildContext context) {
final theme = Theme.of(context);
final titleStyle = theme.textTheme.titleMedium;
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final List<Widget> children = [];
// Add media widget
if (mediaWidget != null) {
children.add(mediaWidget!);
}
// Add title and subtitle
if (title != null || subtitle != null) {
children.add(
Padding(
padding: const EdgeInsets.all(AppSpacing.cardPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: subtitleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
// Add main content
if (child != null) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.cardPadding,
).copyWith(
bottom: AppSpacing.cardPadding,
),
child: child!,
),
);
}
// Add actions
if (actions != null && actions!.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.cardPadding,
).copyWith(
bottom: AppSpacing.cardPadding,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
.take(actions!.length * 2 - 1)
.toList(),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
/// Get elevation based on variant and theme
double _getElevation(ThemeData theme) {
if (elevation != null) {
return elevation!;
}
return switch (variant) {
AppCardVariant.elevated => theme.cardTheme.elevation ?? AppSpacing.elevationLow,
AppCardVariant.filled => AppSpacing.elevationNone,
AppCardVariant.outlined => AppSpacing.elevationNone,
};
}
}
/// Available card variants
enum AppCardVariant {
/// Elevated card with shadow (default)
elevated,
/// Filled card with background color and no shadow
filled,
/// Outlined card with border and no shadow
outlined,
}
/// Compact card for list items
class AppListCard extends StatelessWidget {
const AppListCard({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.padding,
this.margin,
this.semanticLabel,
});
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsets? padding;
final EdgeInsets? margin;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
return AppCard(
variant: AppCardVariant.filled,
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
margin: margin ?? const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.xs,
),
onTap: onTap,
semanticLabel: semanticLabel,
child: Row(
children: [
if (leading != null) ...[
leading!,
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (trailing != null) ...[
AppSpacing.horizontalSpaceSM,
trailing!,
],
],
),
);
}
}
/// Action card for interactive content
class AppActionCard extends StatelessWidget {
const AppActionCard({
super.key,
required this.title,
required this.onTap,
this.subtitle,
this.icon,
this.backgroundColor,
this.foregroundColor,
this.padding,
this.margin,
this.semanticLabel,
});
final String title;
final String? subtitle;
final IconData? icon;
final VoidCallback onTap;
final Color? backgroundColor;
final Color? foregroundColor;
final EdgeInsets? padding;
final EdgeInsets? margin;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
variant: AppCardVariant.filled,
backgroundColor: backgroundColor ?? theme.colorScheme.surfaceContainerHighest,
padding: padding ?? const EdgeInsets.all(AppSpacing.cardPadding),
margin: margin,
onTap: onTap,
semanticLabel: semanticLabel,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: AppSpacing.iconXL,
color: foregroundColor ?? theme.colorScheme.primary,
),
AppSpacing.verticalSpaceMD,
],
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color: foregroundColor,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: foregroundColor ?? theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}

View File

@@ -0,0 +1,696 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
import 'app_button.dart';
/// Custom dialog components with Material 3 styling and consistent design
///
/// Provides reusable dialog widgets for common use cases like confirmation,
/// information display, and custom content dialogs.
class AppDialog extends StatelessWidget {
/// Creates a dialog with the specified content and actions
const AppDialog({
super.key,
this.title,
this.content,
this.actions,
this.icon,
this.backgroundColor,
this.elevation,
this.shape,
this.insetPadding,
this.contentPadding,
this.actionsPadding,
this.semanticLabel,
}) : onConfirm = null,
onCancel = null,
onOk = null,
confirmText = '',
cancelText = '',
okText = '',
isDestructive = false;
/// Creates a confirmation dialog
const AppDialog.confirmation({
super.key,
required this.title,
required this.content,
this.icon,
required this.onConfirm,
required this.onCancel,
this.confirmText = 'Confirm',
this.cancelText = 'Cancel',
this.isDestructive = false,
this.backgroundColor,
this.elevation,
this.shape,
this.insetPadding,
this.contentPadding,
this.actionsPadding,
this.semanticLabel,
}) : actions = null,
onOk = null,
okText = '';
/// Creates an information dialog
const AppDialog.info({
super.key,
required this.title,
required this.content,
this.icon = Icons.info_outline,
this.onOk,
this.okText = 'OK',
this.backgroundColor,
this.elevation,
this.shape,
this.insetPadding,
this.contentPadding,
this.actionsPadding,
this.semanticLabel,
}) : actions = null,
onConfirm = null,
onCancel = null,
confirmText = 'OK',
cancelText = '',
isDestructive = false;
/// Creates a warning dialog
const AppDialog.warning({
super.key,
required this.title,
required this.content,
this.icon = Icons.warning_outlined,
required this.onConfirm,
required this.onCancel,
this.confirmText = 'Continue',
this.cancelText = 'Cancel',
this.backgroundColor,
this.elevation,
this.shape,
this.insetPadding,
this.contentPadding,
this.actionsPadding,
this.semanticLabel,
}) : actions = null,
isDestructive = true,
onOk = null,
okText = '';
/// Creates an error dialog
const AppDialog.error({
super.key,
this.title = 'Error',
required this.content,
this.icon = Icons.error_outline,
this.onOk,
this.okText = 'OK',
this.backgroundColor,
this.elevation,
this.shape,
this.insetPadding,
this.contentPadding,
this.actionsPadding,
this.semanticLabel,
}) : actions = null,
onConfirm = null,
onCancel = null,
confirmText = 'OK',
cancelText = '',
isDestructive = false;
/// Dialog title
final String? title;
/// Dialog content (text or widget)
final dynamic content;
/// Custom action widgets
final List<Widget>? actions;
/// Dialog icon
final IconData? icon;
/// Confirm button callback
final VoidCallback? onConfirm;
/// Cancel button callback
final VoidCallback? onCancel;
/// OK button callback (for info/error dialogs)
final VoidCallback? onOk;
/// Confirm button text
final String confirmText;
/// Cancel button text
final String cancelText;
/// OK button text
final String okText;
/// Whether the confirm action is destructive
final bool isDestructive;
/// Background color override
final Color? backgroundColor;
/// Elevation override
final double? elevation;
/// Shape override
final ShapeBorder? shape;
/// Inset padding override
final EdgeInsets? insetPadding;
/// Content padding override
final EdgeInsets? contentPadding;
/// Actions padding override
final EdgeInsets? actionsPadding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget? titleWidget;
if (title != null || icon != null) {
titleWidget = _buildTitle(context);
}
Widget? contentWidget;
if (content != null) {
contentWidget = _buildContent(context);
}
List<Widget>? actionWidgets;
if (actions != null) {
actionWidgets = actions;
} else {
actionWidgets = _buildDefaultActions(context);
}
Widget dialog = AlertDialog(
title: titleWidget,
content: contentWidget,
actions: actionWidgets,
backgroundColor: backgroundColor,
elevation: elevation,
shape: shape ??
RoundedRectangleBorder(
borderRadius: AppSpacing.dialogRadius,
),
insetPadding: insetPadding ??
const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.screenPaddingLarge,
),
contentPadding: contentPadding ??
const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
AppSpacing.screenPaddingLarge,
AppSpacing.sm,
),
actionsPadding: actionsPadding ??
const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
0,
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
),
);
// Add semantic label for accessibility
if (semanticLabel != null) {
dialog = Semantics(
label: semanticLabel,
child: dialog,
);
}
return dialog;
}
/// Build dialog title with optional icon
Widget _buildTitle(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (icon != null && title != null) {
return Row(
children: [
Icon(
icon,
size: AppSpacing.iconMD,
color: _getIconColor(colorScheme),
),
AppSpacing.horizontalSpaceMD,
Expanded(
child: Text(
title!,
style: theme.dialogTheme.titleTextStyle ??
AppTypography.headlineSmall,
),
),
],
);
} else if (icon != null) {
return Icon(
icon,
size: AppSpacing.iconLG,
color: _getIconColor(colorScheme),
);
} else if (title != null) {
return Text(
title!,
style: theme.dialogTheme.titleTextStyle ??
AppTypography.headlineSmall,
);
}
return const SizedBox.shrink();
}
/// Build dialog content
Widget _buildContent(BuildContext context) {
final theme = Theme.of(context);
if (content is Widget) {
return content as Widget;
} else if (content is String) {
return Text(
content as String,
style: theme.dialogTheme.contentTextStyle ??
AppTypography.bodyMedium,
);
}
return const SizedBox.shrink();
}
/// Build default action buttons based on dialog type
List<Widget>? _buildDefaultActions(BuildContext context) {
// Info/Error dialog with single OK button
if (onOk != null) {
return [
AppButton(
text: okText,
onPressed: () {
Navigator.of(context).pop();
onOk?.call();
},
variant: AppButtonVariant.text,
),
];
}
// Confirmation dialog with Cancel and Confirm buttons
if (onConfirm != null && onCancel != null) {
return [
AppButton(
text: cancelText,
onPressed: () {
Navigator.of(context).pop(false);
onCancel?.call();
},
variant: AppButtonVariant.text,
),
AppSpacing.horizontalSpaceSM,
AppButton(
text: confirmText,
onPressed: () {
Navigator.of(context).pop(true);
onConfirm?.call();
},
variant: isDestructive
? AppButtonVariant.filled
: AppButtonVariant.filled,
backgroundColor: isDestructive
? Theme.of(context).colorScheme.error
: null,
),
];
}
return null;
}
/// Get appropriate icon color based on dialog type
Color _getIconColor(ColorScheme colorScheme) {
if (icon == Icons.error_outline) {
return colorScheme.error;
} else if (icon == Icons.warning_outlined) {
return colorScheme.error;
} else if (icon == Icons.info_outline) {
return colorScheme.primary;
}
return colorScheme.onSurfaceVariant;
}
/// Show this dialog
static Future<T?> show<T extends Object?>({
required BuildContext context,
required AppDialog dialog,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
builder: (context) => dialog,
);
}
}
/// Simple confirmation dialog helper
class AppConfirmDialog {
/// Show a confirmation dialog
static Future<bool?> show({
required BuildContext context,
required String title,
required String message,
String confirmText = 'Confirm',
String cancelText = 'Cancel',
bool isDestructive = false,
IconData? icon,
bool barrierDismissible = true,
}) {
return AppDialog.show<bool>(
context: context,
barrierDismissible: barrierDismissible,
dialog: AppDialog.confirmation(
title: title,
content: message,
icon: icon,
confirmText: confirmText,
cancelText: cancelText,
isDestructive: isDestructive,
onConfirm: () {},
onCancel: () {},
),
);
}
/// Show a delete confirmation dialog
static Future<bool?> showDelete({
required BuildContext context,
String title = 'Delete Item',
String message = 'Are you sure you want to delete this item? This action cannot be undone.',
String confirmText = 'Delete',
String cancelText = 'Cancel',
bool barrierDismissible = true,
}) {
return show(
context: context,
title: title,
message: message,
confirmText: confirmText,
cancelText: cancelText,
isDestructive: true,
icon: Icons.delete_outline,
barrierDismissible: barrierDismissible,
);
}
}
/// Simple info dialog helper
class AppInfoDialog {
/// Show an information dialog
static Future<void> show({
required BuildContext context,
required String title,
required String message,
String okText = 'OK',
IconData icon = Icons.info_outline,
bool barrierDismissible = true,
}) {
return AppDialog.show<void>(
context: context,
barrierDismissible: barrierDismissible,
dialog: AppDialog.info(
title: title,
content: message,
icon: icon,
okText: okText,
onOk: () {},
),
);
}
/// Show a success dialog
static Future<void> showSuccess({
required BuildContext context,
String title = 'Success',
required String message,
String okText = 'OK',
bool barrierDismissible = true,
}) {
return show(
context: context,
title: title,
message: message,
okText: okText,
icon: Icons.check_circle_outline,
barrierDismissible: barrierDismissible,
);
}
/// Show an error dialog
static Future<void> showError({
required BuildContext context,
String title = 'Error',
required String message,
String okText = 'OK',
bool barrierDismissible = true,
}) {
return AppDialog.show<void>(
context: context,
barrierDismissible: barrierDismissible,
dialog: AppDialog.error(
title: title,
content: message,
okText: okText,
onOk: () {},
),
);
}
}
/// Loading dialog that shows a progress indicator
class AppLoadingDialog extends StatelessWidget {
/// Creates a loading dialog
const AppLoadingDialog({
super.key,
this.message = 'Loading...',
this.showProgress = false,
this.progress,
this.barrierDismissible = false,
});
/// Loading message
final String message;
/// Whether to show determinate progress
final bool showProgress;
/// Progress value (0.0 to 1.0)
final double? progress;
/// Whether the dialog can be dismissed
final bool barrierDismissible;
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: AppSpacing.dialogRadius,
),
content: IntrinsicHeight(
child: Column(
children: [
if (showProgress && progress != null)
LinearProgressIndicator(value: progress)
else
const CircularProgressIndicator(),
AppSpacing.verticalSpaceLG,
Text(
message,
style: AppTypography.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Show loading dialog
static Future<T> show<T>({
required BuildContext context,
required Future<T> future,
String message = 'Loading...',
bool showProgress = false,
bool barrierDismissible = false,
}) async {
showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
builder: (context) => AppLoadingDialog(
message: message,
showProgress: showProgress,
barrierDismissible: barrierDismissible,
),
);
try {
final result = await future;
if (context.mounted) {
Navigator.of(context).pop();
}
return result;
} catch (error) {
if (context.mounted) {
Navigator.of(context).pop();
}
rethrow;
}
}
}
/// Custom bottom sheet dialog
class AppBottomSheetDialog extends StatelessWidget {
/// Creates a bottom sheet dialog
const AppBottomSheetDialog({
super.key,
this.title,
required this.child,
this.actions,
this.showDragHandle = true,
this.isScrollControlled = false,
this.maxHeight,
this.padding,
this.semanticLabel,
});
/// Dialog title
final String? title;
/// Dialog content
final Widget child;
/// Action buttons
final List<Widget>? actions;
/// Whether to show drag handle
final bool showDragHandle;
/// Whether the sheet should be full screen
final bool isScrollControlled;
/// Maximum height of the sheet
final double? maxHeight;
/// Content padding
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final defaultMaxHeight = mediaQuery.size.height * 0.9;
Widget content = Container(
constraints: BoxConstraints(
maxHeight: maxHeight ?? defaultMaxHeight,
),
decoration: BoxDecoration(
color: theme.bottomSheetTheme.backgroundColor ??
theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showDragHandle)
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
if (title != null)
Padding(
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
child: Text(
title!,
style: AppTypography.headlineSmall,
textAlign: TextAlign.center,
),
),
Flexible(
child: Padding(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPaddingLarge,
),
child: child,
),
),
if (actions != null && actions!.isNotEmpty)
Padding(
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
.take(actions!.length * 2 - 1)
.toList(),
),
),
],
),
);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
/// Show bottom sheet dialog
static Future<T?> show<T>({
required BuildContext context,
required AppBottomSheetDialog dialog,
bool isDismissible = true,
bool enableDrag = true,
}) {
return showModalBottomSheet<T>(
context: context,
builder: (context) => dialog,
isScrollControlled: dialog.isScrollControlled,
isDismissible: isDismissible,
enableDrag: enableDrag,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
),
);
}
}

View File

@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
import 'app_button.dart';
/// Empty state widget with icon, message, and optional action button
///
/// Provides a consistent empty state design with customizable content
/// and actions for when no data is available.
class AppEmptyState extends StatelessWidget {
/// Creates an empty state widget with the specified content
const AppEmptyState({
super.key,
this.icon,
this.title,
this.message,
this.actionText,
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.illustration,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
});
/// Creates a "no data" empty state
const AppEmptyState.noData({
super.key,
this.title = 'No data available',
this.message = 'There is no data to display at the moment.',
this.actionText,
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.inbox_outlined,
illustration = null;
/// Creates a "no search results" empty state
const AppEmptyState.noSearchResults({
super.key,
this.title = 'No results found',
this.message = 'Try adjusting your search criteria or filters.',
this.actionText = 'Clear filters',
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.search_off_outlined,
illustration = null;
/// Creates a "no network" empty state
const AppEmptyState.noNetwork({
super.key,
this.title = 'No internet connection',
this.message = 'Please check your network connection and try again.',
this.actionText = 'Retry',
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.wifi_off_outlined,
illustration = null;
/// Creates an "error" empty state
const AppEmptyState.error({
super.key,
this.title = 'Something went wrong',
this.message = 'An error occurred while loading the data.',
this.actionText = 'Try again',
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.error_outline,
illustration = null;
/// Creates a "coming soon" empty state
const AppEmptyState.comingSoon({
super.key,
this.title = 'Coming soon',
this.message = 'This feature is currently under development.',
this.actionText,
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.construction_outlined,
illustration = null;
/// Creates an "access denied" empty state
const AppEmptyState.accessDenied({
super.key,
this.title = 'Access denied',
this.message = 'You do not have permission to view this content.',
this.actionText = 'Request access',
this.onActionPressed,
this.secondaryActionText,
this.onSecondaryActionPressed,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.lock_outline,
illustration = null;
/// Icon to display (ignored if illustration is provided)
final IconData? icon;
/// Custom illustration widget
final Widget? illustration;
/// Main title text
final String? title;
/// Descriptive message text
final String? message;
/// Primary action button text
final String? actionText;
/// Callback for primary action
final VoidCallback? onActionPressed;
/// Secondary action button text
final String? secondaryActionText;
/// Callback for secondary action
final VoidCallback? onSecondaryActionPressed;
/// Maximum width of the empty state content
final double maxWidth;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icon or illustration
if (illustration != null)
illustration!
else if (icon != null)
Icon(
icon,
size: AppSpacing.iconXXL * 2, // 96dp
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
),
AppSpacing.verticalSpaceXXL,
// Title
if (title != null)
Text(
title!,
style: AppTypography.headlineSmall.copyWith(
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Message
if (message != null) ...[
if (title != null) AppSpacing.verticalSpaceMD,
Text(
message!,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
// Actions
if (actionText != null || secondaryActionText != null) ...[
AppSpacing.verticalSpaceXXL,
_buildActions(context),
],
],
);
// Apply maximum width constraint
content = ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: content,
);
// Apply padding
content = Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.screenPadding),
child: content,
);
// Center the content
content = Center(child: content);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
/// Build action buttons
Widget _buildActions(BuildContext context) {
final List<Widget> actions = [];
// Primary action
if (actionText != null && onActionPressed != null) {
actions.add(
AppButton(
text: actionText!,
onPressed: onActionPressed,
variant: AppButtonVariant.filled,
isFullWidth: secondaryActionText == null, // Full width if only one button
),
);
}
// Secondary action
if (secondaryActionText != null && onSecondaryActionPressed != null) {
if (actions.isNotEmpty) {
actions.add(AppSpacing.verticalSpaceMD);
}
actions.add(
AppButton(
text: secondaryActionText!,
onPressed: onSecondaryActionPressed,
variant: AppButtonVariant.outlined,
isFullWidth: true,
),
);
}
// If both actions exist and we want them side by side
if (actions.length >= 3) {
return Column(children: actions);
}
// Single action or side-by-side layout
if (actions.length == 1) {
return actions.first;
}
return Column(children: actions);
}
}
/// Compact empty state for smaller spaces (like lists)
class AppCompactEmptyState extends StatelessWidget {
/// Creates a compact empty state widget
const AppCompactEmptyState({
super.key,
this.icon = Icons.inbox_outlined,
required this.message,
this.actionText,
this.onActionPressed,
this.padding,
this.semanticLabel,
});
/// Icon to display
final IconData icon;
/// Message text
final String message;
/// Action button text
final String? actionText;
/// Callback for action
final VoidCallback? onActionPressed;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: AppSpacing.iconLG,
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
),
AppSpacing.verticalSpaceMD,
Text(
message,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (actionText != null && onActionPressed != null) ...[
AppSpacing.verticalSpaceMD,
AppButton(
text: actionText!,
onPressed: onActionPressed,
variant: AppButtonVariant.text,
size: AppButtonSize.small,
),
],
],
);
// Apply padding
content = Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.lg),
child: content,
);
// Center the content
content = Center(child: content);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
}
/// Empty state for specific list/grid scenarios
class AppListEmptyState extends StatelessWidget {
/// Creates an empty state for lists
const AppListEmptyState({
super.key,
this.type = AppListEmptyType.noItems,
this.title,
this.message,
this.actionText,
this.onActionPressed,
this.searchQuery,
this.padding,
this.semanticLabel,
});
/// Type of empty state
final AppListEmptyType type;
/// Custom title (overrides default)
final String? title;
/// Custom message (overrides default)
final String? message;
/// Action button text
final String? actionText;
/// Callback for action
final VoidCallback? onActionPressed;
/// Search query for search results empty state
final String? searchQuery;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final config = _getEmptyStateConfig();
return AppEmptyState(
icon: config.icon,
title: title ?? config.title,
message: message ?? _getMessageWithQuery(config.message),
actionText: actionText ?? config.actionText,
onActionPressed: onActionPressed,
padding: padding,
semanticLabel: semanticLabel,
);
}
/// Get configuration based on type
_EmptyStateConfig _getEmptyStateConfig() {
return switch (type) {
AppListEmptyType.noItems => _EmptyStateConfig(
icon: Icons.inbox_outlined,
title: 'No items',
message: 'There are no items to display.',
actionText: 'Add item',
),
AppListEmptyType.noFavorites => _EmptyStateConfig(
icon: Icons.favorite_border_outlined,
title: 'No favorites',
message: 'Items you mark as favorite will appear here.',
actionText: 'Browse items',
),
AppListEmptyType.noSearchResults => _EmptyStateConfig(
icon: Icons.search_off_outlined,
title: 'No results found',
message: 'No results found for your search.',
actionText: 'Clear search',
),
AppListEmptyType.noNotifications => _EmptyStateConfig(
icon: Icons.notifications_none_outlined,
title: 'No notifications',
message: 'You have no new notifications.',
actionText: null,
),
AppListEmptyType.noMessages => _EmptyStateConfig(
icon: Icons.message_outlined,
title: 'No messages',
message: 'You have no messages yet.',
actionText: 'Start conversation',
),
AppListEmptyType.noHistory => _EmptyStateConfig(
icon: Icons.history,
title: 'No history',
message: 'Your activity history will appear here.',
actionText: null,
),
};
}
/// Get message with search query if applicable
String _getMessageWithQuery(String defaultMessage) {
if (type == AppListEmptyType.noSearchResults && searchQuery != null) {
return 'No results found for "$searchQuery". Try different keywords.';
}
return defaultMessage;
}
}
/// Configuration for empty state
class _EmptyStateConfig {
const _EmptyStateConfig({
required this.icon,
required this.title,
required this.message,
this.actionText,
});
final IconData icon;
final String title;
final String message;
final String? actionText;
}
/// Types of list empty states
enum AppListEmptyType {
/// Generic no items state
noItems,
/// No favorite items
noFavorites,
/// No search results
noSearchResults,
/// No notifications
noNotifications,
/// No messages
noMessages,
/// No history
noHistory,
}

View File

@@ -0,0 +1,579 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
import 'app_button.dart';
/// Error display widget with retry action and customizable styling
///
/// Provides consistent error handling UI with support for different error types,
/// retry functionality, and accessibility features.
class AppErrorWidget extends StatelessWidget {
/// Creates an error widget with the specified configuration
const AppErrorWidget({
super.key,
this.error,
this.stackTrace,
this.title,
this.message,
this.icon,
this.onRetry,
this.retryText = 'Retry',
this.secondaryActionText,
this.onSecondaryAction,
this.showDetails = false,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
});
/// Creates a network error widget
const AppErrorWidget.network({
super.key,
this.error,
this.stackTrace,
this.title = 'Network Error',
this.message = 'Please check your internet connection and try again.',
this.onRetry,
this.retryText = 'Retry',
this.secondaryActionText,
this.onSecondaryAction,
this.showDetails = false,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.wifi_off_outlined;
/// Creates a server error widget
const AppErrorWidget.server({
super.key,
this.error,
this.stackTrace,
this.title = 'Server Error',
this.message = 'Something went wrong on our end. Please try again later.',
this.onRetry,
this.retryText = 'Retry',
this.secondaryActionText,
this.onSecondaryAction,
this.showDetails = false,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.dns_outlined;
/// Creates a not found error widget
const AppErrorWidget.notFound({
super.key,
this.error,
this.stackTrace,
this.title = 'Not Found',
this.message = 'The requested content could not be found.',
this.onRetry,
this.retryText = 'Go Back',
this.secondaryActionText,
this.onSecondaryAction,
this.showDetails = false,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.search_off_outlined;
/// Creates a generic error widget
const AppErrorWidget.generic({
super.key,
this.error,
this.stackTrace,
this.title = 'Something went wrong',
this.message = 'An unexpected error occurred. Please try again.',
this.onRetry,
this.retryText = 'Retry',
this.secondaryActionText,
this.onSecondaryAction,
this.showDetails = false,
this.maxWidth = 320,
this.padding,
this.semanticLabel,
}) : icon = Icons.error_outline;
/// The error object (for development/debugging)
final Object? error;
/// Stack trace (for development/debugging)
final StackTrace? stackTrace;
/// Error title
final String? title;
/// Error message
final String? message;
/// Error icon
final IconData? icon;
/// Retry callback
final VoidCallback? onRetry;
/// Retry button text
final String retryText;
/// Secondary action text
final String? secondaryActionText;
/// Secondary action callback
final VoidCallback? onSecondaryAction;
/// Whether to show error details (for debugging)
final bool showDetails;
/// Maximum width of the error content
final double maxWidth;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDebug = _isDebugMode();
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
// Error icon
if (icon != null)
Icon(
icon,
size: AppSpacing.iconXXL * 1.5, // 72dp
color: colorScheme.error.withOpacity(0.8),
),
AppSpacing.verticalSpaceXL,
// Error title
if (title != null)
Text(
title!,
style: AppTypography.headlineSmall.copyWith(
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Error message
if (message != null) ...[
if (title != null) AppSpacing.verticalSpaceMD,
Text(
message!,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
// Error details (debug mode only)
if (isDebug && showDetails && error != null) ...[
AppSpacing.verticalSpaceLG,
_buildErrorDetails(context),
],
// Action buttons
if (onRetry != null || onSecondaryAction != null) ...[
AppSpacing.verticalSpaceXXL,
_buildActions(context),
],
],
);
// Apply maximum width constraint
content = ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: content,
);
// Apply padding
content = Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.screenPadding),
child: content,
);
// Center the content
content = Center(child: content);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
/// Build action buttons
Widget _buildActions(BuildContext context) {
final List<Widget> actions = [];
// Retry button
if (onRetry != null) {
actions.add(
AppButton(
text: retryText,
onPressed: onRetry,
variant: AppButtonVariant.filled,
isFullWidth: secondaryActionText == null,
),
);
}
// Secondary action
if (secondaryActionText != null && onSecondaryAction != null) {
if (actions.isNotEmpty) {
actions.add(AppSpacing.verticalSpaceMD);
}
actions.add(
AppButton(
text: secondaryActionText!,
onPressed: onSecondaryAction,
variant: AppButtonVariant.outlined,
isFullWidth: true,
),
);
}
return Column(children: actions);
}
/// Build error details for debugging
Widget _buildErrorDetails(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.1),
borderRadius: AppSpacing.radiusMD,
border: Border.all(
color: colorScheme.error.withOpacity(0.3),
width: AppSpacing.borderWidth,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bug_report_outlined,
size: AppSpacing.iconSM,
color: colorScheme.error,
),
AppSpacing.horizontalSpaceXS,
Text(
'Debug Information',
style: AppTypography.labelMedium.copyWith(
color: colorScheme.error,
),
),
],
),
AppSpacing.verticalSpaceXS,
Text(
error.toString(),
style: AppTypography.bodySmall.copyWith(
color: colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
if (stackTrace != null) ...[
AppSpacing.verticalSpaceXS,
Text(
'Stack trace available (check console)',
style: AppTypography.bodySmall.copyWith(
color: colorScheme.onErrorContainer.withOpacity(0.7),
fontStyle: FontStyle.italic,
),
),
],
],
),
);
}
/// Check if app is in debug mode
bool _isDebugMode() {
bool inDebugMode = false;
assert(inDebugMode = true);
return inDebugMode;
}
}
/// Compact error widget for smaller spaces
class AppCompactErrorWidget extends StatelessWidget {
/// Creates a compact error widget
const AppCompactErrorWidget({
super.key,
this.error,
this.message = 'An error occurred',
this.onRetry,
this.retryText = 'Retry',
this.padding,
this.semanticLabel,
});
/// The error object
final Object? error;
/// Error message
final String message;
/// Retry callback
final VoidCallback? onRetry;
/// Retry button text
final String retryText;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: AppSpacing.iconLG,
color: colorScheme.error.withOpacity(0.8),
),
AppSpacing.verticalSpaceMD,
Text(
message,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (onRetry != null) ...[
AppSpacing.verticalSpaceMD,
AppButton(
text: retryText,
onPressed: onRetry,
variant: AppButtonVariant.text,
size: AppButtonSize.small,
),
],
],
);
// Apply padding
content = Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.lg),
child: content,
);
// Center the content
content = Center(child: content);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
}
/// Inline error widget for form fields and small spaces
class AppInlineErrorWidget extends StatelessWidget {
/// Creates an inline error widget
const AppInlineErrorWidget({
super.key,
required this.message,
this.icon = Icons.error_outline,
this.onRetry,
this.retryText = 'Retry',
this.color,
this.backgroundColor,
this.padding,
this.semanticLabel,
});
/// Error message
final String message;
/// Error icon
final IconData icon;
/// Retry callback
final VoidCallback? onRetry;
/// Retry button text
final String retryText;
/// Error color override
final Color? color;
/// Background color override
final Color? backgroundColor;
/// Padding around the content
final EdgeInsets? padding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final errorColor = color ?? colorScheme.error;
Widget content = Container(
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: backgroundColor ?? errorColor.withOpacity(0.1),
borderRadius: AppSpacing.radiusSM,
border: Border.all(
color: errorColor.withOpacity(0.3),
width: AppSpacing.borderWidth,
),
),
child: Row(
children: [
Icon(
icon,
size: AppSpacing.iconSM,
color: errorColor,
),
AppSpacing.horizontalSpaceXS,
Expanded(
child: Text(
message,
style: AppTypography.bodySmall.copyWith(
color: errorColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (onRetry != null) ...[
AppSpacing.horizontalSpaceSM,
AppButton(
text: retryText,
onPressed: onRetry,
variant: AppButtonVariant.text,
size: AppButtonSize.small,
),
],
],
),
);
// Add semantic label for accessibility
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
child: content,
);
}
return content;
}
}
/// Error boundary widget that catches errors in child widgets
class AppErrorBoundary extends StatefulWidget {
/// Creates an error boundary that catches errors in child widgets
const AppErrorBoundary({
super.key,
required this.child,
this.onError,
this.errorWidgetBuilder,
this.fallbackWidget,
});
/// The child widget to wrap with error handling
final Widget child;
/// Callback when an error occurs
final void Function(Object error, StackTrace stackTrace)? onError;
/// Custom error widget builder
final Widget Function(Object error, StackTrace stackTrace)? errorWidgetBuilder;
/// Fallback widget to show when error occurs (used if errorWidgetBuilder is null)
final Widget? fallbackWidget;
@override
State<AppErrorBoundary> createState() => _AppErrorBoundaryState();
}
class _AppErrorBoundaryState extends State<AppErrorBoundary> {
Object? _error;
StackTrace? _stackTrace;
@override
void initState() {
super.initState();
ErrorWidget.builder = (FlutterErrorDetails details) {
if (mounted) {
setState(() {
_error = details.exception;
_stackTrace = details.stack;
});
}
widget.onError?.call(details.exception, details.stack ?? StackTrace.current);
return widget.errorWidgetBuilder?.call(
details.exception,
details.stack ?? StackTrace.current,
) ?? widget.fallbackWidget ?? const AppErrorWidget.generic();
};
}
@override
Widget build(BuildContext context) {
if (_error != null) {
return widget.errorWidgetBuilder?.call(_error!, _stackTrace!) ??
widget.fallbackWidget ??
AppErrorWidget.generic(
error: _error,
stackTrace: _stackTrace,
onRetry: _handleRetry,
);
}
return widget.child;
}
void _handleRetry() {
setState(() {
_error = null;
_stackTrace = null;
});
}
}

View File

@@ -0,0 +1,529 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
/// Various loading states including circular, linear, and skeleton loaders
///
/// Provides consistent loading indicators with customizable styling
/// and accessibility features.
class AppLoadingIndicator extends StatelessWidget {
/// Creates a loading indicator with the specified type and styling
const AppLoadingIndicator({
super.key,
this.type = AppLoadingType.circular,
this.size = AppLoadingSize.medium,
this.color,
this.backgroundColor,
this.strokeWidth,
this.value,
this.message,
this.semanticLabel,
});
/// The type of loading indicator to display
final AppLoadingType type;
/// The size of the loading indicator
final AppLoadingSize size;
/// Color override for the indicator
final Color? color;
/// Background color override (for linear progress)
final Color? backgroundColor;
/// Stroke width override (for circular progress)
final double? strokeWidth;
/// Progress value (0.0 to 1.0) for determinate progress indicators
final double? value;
/// Optional message to display below the indicator
final String? message;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget indicator = switch (type) {
AppLoadingType.circular => _buildCircularIndicator(colorScheme),
AppLoadingType.linear => _buildLinearIndicator(colorScheme),
AppLoadingType.adaptive => _buildAdaptiveIndicator(context, colorScheme),
AppLoadingType.refresh => _buildRefreshIndicator(colorScheme),
};
// Add message if provided
if (message != null) {
indicator = Column(
mainAxisSize: MainAxisSize.min,
children: [
indicator,
AppSpacing.verticalSpaceMD,
Text(
message!,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
);
}
// Add semantic label for accessibility
if (semanticLabel != null) {
indicator = Semantics(
label: semanticLabel,
child: indicator,
);
}
return indicator;
}
/// Build circular progress indicator
Widget _buildCircularIndicator(ColorScheme colorScheme) {
final indicatorSize = _getIndicatorSize();
return SizedBox(
width: indicatorSize,
height: indicatorSize,
child: CircularProgressIndicator(
value: value,
color: color ?? colorScheme.primary,
backgroundColor: backgroundColor,
strokeWidth: strokeWidth ?? _getStrokeWidth(),
),
);
}
/// Build linear progress indicator
Widget _buildLinearIndicator(ColorScheme colorScheme) {
return LinearProgressIndicator(
value: value,
color: color ?? colorScheme.primary,
backgroundColor: backgroundColor ?? colorScheme.surfaceContainerHighest,
minHeight: _getLinearHeight(),
);
}
/// Build adaptive progress indicator (Material on Android, Cupertino on iOS)
Widget _buildAdaptiveIndicator(BuildContext context, ColorScheme colorScheme) {
final indicatorSize = _getIndicatorSize();
return SizedBox(
width: indicatorSize,
height: indicatorSize,
child: CircularProgressIndicator.adaptive(
value: value,
backgroundColor: backgroundColor,
strokeWidth: strokeWidth ?? _getStrokeWidth(),
),
);
}
/// Build refresh indicator style circular indicator
Widget _buildRefreshIndicator(ColorScheme colorScheme) {
final indicatorSize = _getIndicatorSize();
return SizedBox(
width: indicatorSize,
height: indicatorSize,
child: RefreshProgressIndicator(
value: value,
color: color ?? colorScheme.primary,
backgroundColor: backgroundColor ?? colorScheme.surface,
strokeWidth: strokeWidth ?? _getStrokeWidth(),
),
);
}
/// Get indicator size based on size enum
double _getIndicatorSize() {
return switch (size) {
AppLoadingSize.small => AppSpacing.iconMD,
AppLoadingSize.medium => AppSpacing.iconLG,
AppLoadingSize.large => AppSpacing.iconXL,
AppLoadingSize.extraLarge => AppSpacing.iconXXL,
};
}
/// Get stroke width based on size
double _getStrokeWidth() {
return switch (size) {
AppLoadingSize.small => 2.0,
AppLoadingSize.medium => 3.0,
AppLoadingSize.large => 4.0,
AppLoadingSize.extraLarge => 5.0,
};
}
/// Get linear indicator height based on size
double _getLinearHeight() {
return switch (size) {
AppLoadingSize.small => 2.0,
AppLoadingSize.medium => 4.0,
AppLoadingSize.large => 6.0,
AppLoadingSize.extraLarge => 8.0,
};
}
}
/// Available loading indicator types
enum AppLoadingType {
/// Circular progress indicator
circular,
/// Linear progress indicator
linear,
/// Adaptive indicator (platform-specific)
adaptive,
/// Refresh indicator style
refresh,
}
/// Available loading indicator sizes
enum AppLoadingSize {
/// Small indicator (24dp)
small,
/// Medium indicator (32dp) - default
medium,
/// Large indicator (40dp)
large,
/// Extra large indicator (48dp)
extraLarge,
}
/// Skeleton loader for content placeholders
class AppSkeletonLoader extends StatefulWidget {
/// Creates a skeleton loader with the specified dimensions and styling
const AppSkeletonLoader({
super.key,
this.width,
this.height = 16.0,
this.borderRadius,
this.baseColor,
this.highlightColor,
this.animationDuration = const Duration(milliseconds: 1500),
});
/// Creates a rectangular skeleton loader
const AppSkeletonLoader.rectangle({
super.key,
required this.width,
required this.height,
this.borderRadius,
this.baseColor,
this.highlightColor,
this.animationDuration = const Duration(milliseconds: 1500),
});
/// Creates a circular skeleton loader (avatar placeholder)
AppSkeletonLoader.circle({
super.key,
required double diameter,
this.baseColor,
this.highlightColor,
this.animationDuration = const Duration(milliseconds: 1500),
}) : width = diameter,
height = diameter,
borderRadius = BorderRadius.circular(diameter / 2.0);
/// Creates a text line skeleton loader
const AppSkeletonLoader.text({
super.key,
this.width,
this.height = 16.0,
this.baseColor,
this.highlightColor,
this.animationDuration = const Duration(milliseconds: 1500),
}) : borderRadius = const BorderRadius.all(Radius.circular(8.0));
/// Width of the skeleton loader
final double? width;
/// Height of the skeleton loader
final double height;
/// Border radius for rounded corners
final BorderRadius? borderRadius;
/// Base color for the skeleton
final Color? baseColor;
/// Highlight color for the animation
final Color? highlightColor;
/// Duration of the shimmer animation
final Duration animationDuration;
@override
State<AppSkeletonLoader> createState() => _AppSkeletonLoaderState();
}
class _AppSkeletonLoaderState extends State<AppSkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.ease),
);
_animationController.repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final baseColor = widget.baseColor ??
(theme.brightness == Brightness.light
? colorScheme.surfaceContainerHighest
: colorScheme.surfaceContainerLowest);
final highlightColor = widget.highlightColor ??
(theme.brightness == Brightness.light
? colorScheme.surface
: colorScheme.surfaceContainerHigh);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? AppSpacing.radiusSM,
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: [
0.0,
0.5,
1.0,
],
transform: GradientRotation(_animation.value),
),
),
);
},
);
}
}
/// Skeleton loader for list items
class AppListItemSkeleton extends StatelessWidget {
/// Creates a skeleton loader for list items with avatar and text
const AppListItemSkeleton({
super.key,
this.hasAvatar = true,
this.hasSubtitle = true,
this.hasTrailing = false,
this.padding,
});
/// Whether to show avatar placeholder
final bool hasAvatar;
/// Whether to show subtitle placeholder
final bool hasSubtitle;
/// Whether to show trailing element placeholder
final bool hasTrailing;
/// Padding around the skeleton
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
if (hasAvatar) ...[
AppSkeletonLoader.circle(diameter: 40.0),
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSkeletonLoader.text(width: double.infinity),
if (hasSubtitle) ...[
AppSpacing.verticalSpaceXS,
const AppSkeletonLoader.text(width: 200),
],
],
),
),
if (hasTrailing) ...[
AppSpacing.horizontalSpaceMD,
const AppSkeletonLoader.rectangle(width: 60, height: 32),
],
],
),
);
}
}
/// Skeleton loader for card content
class AppCardSkeleton extends StatelessWidget {
/// Creates a skeleton loader for card content
const AppCardSkeleton({
super.key,
this.hasImage = false,
this.hasTitle = true,
this.hasSubtitle = true,
this.hasContent = true,
this.hasActions = false,
this.padding,
});
/// Whether to show image placeholder
final bool hasImage;
/// Whether to show title placeholder
final bool hasTitle;
/// Whether to show subtitle placeholder
final bool hasSubtitle;
/// Whether to show content placeholder
final bool hasContent;
/// Whether to show action buttons placeholder
final bool hasActions;
/// Padding around the skeleton
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.all(AppSpacing.cardPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasImage) ...[
const AppSkeletonLoader.rectangle(
width: double.infinity,
height: 200,
),
AppSpacing.verticalSpaceMD,
],
if (hasTitle) ...[
const AppSkeletonLoader.text(width: 250),
AppSpacing.verticalSpaceXS,
],
if (hasSubtitle) ...[
const AppSkeletonLoader.text(width: 180),
AppSpacing.verticalSpaceMD,
],
if (hasContent) ...[
const AppSkeletonLoader.text(width: double.infinity),
AppSpacing.verticalSpaceXS,
const AppSkeletonLoader.text(width: double.infinity),
AppSpacing.verticalSpaceXS,
const AppSkeletonLoader.text(width: 300),
],
if (hasActions) ...[
AppSpacing.verticalSpaceMD,
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const AppSkeletonLoader.rectangle(width: 80, height: 36),
AppSpacing.horizontalSpaceSM,
const AppSkeletonLoader.rectangle(width: 80, height: 36),
],
),
],
],
),
);
}
}
/// Loading overlay that can be displayed over content
class AppLoadingOverlay extends StatelessWidget {
/// Creates a loading overlay with the specified content and styling
const AppLoadingOverlay({
super.key,
required this.isLoading,
required this.child,
this.loadingWidget,
this.backgroundColor,
this.message,
this.dismissible = false,
});
/// Whether the loading overlay should be displayed
final bool isLoading;
/// The content to display behind the overlay
final Widget child;
/// Custom loading widget (defaults to circular indicator)
final Widget? loadingWidget;
/// Background color of the overlay
final Color? backgroundColor;
/// Optional message to display with the loading indicator
final String? message;
/// Whether the overlay can be dismissed by tapping
final bool dismissible;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
children: [
child,
if (isLoading)
Positioned.fill(
child: Container(
color: backgroundColor ??
theme.colorScheme.surface.withOpacity(0.8),
child: Center(
child: loadingWidget ??
AppLoadingIndicator(
message: message,
semanticLabel: 'Loading content',
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,621 @@
import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
/// Snackbar utilities for consistent notifications and user feedback
///
/// Provides easy-to-use static methods for showing different types of
/// snackbars with Material 3 styling and customization options.
class AppSnackbar {
// Prevent instantiation
AppSnackbar._();
/// Show a basic snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
Color? backgroundColor,
Color? textColor,
double? elevation,
EdgeInsets? margin,
ShapeBorder? shape,
SnackBarBehavior behavior = SnackBarBehavior.floating,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final snackBarTheme = theme.snackBarTheme;
final snackBar = SnackBar(
content: Text(
message,
style: AppTypography.bodyMedium.copyWith(
color: textColor ?? snackBarTheme.contentTextStyle?.color,
),
),
duration: duration,
action: action,
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
elevation: elevation ?? snackBarTheme.elevation,
margin: margin ?? _getDefaultMargin(context),
shape: shape ?? snackBarTheme.shape,
behavior: behavior,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
/// Show a success snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSuccess({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 3),
SnackBarAction? action,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final successColor = _getSuccessColor(theme);
return _showWithIcon(
context: context,
message: message,
icon: Icons.check_circle_outline,
backgroundColor: successColor,
textColor: _getOnColor(successColor),
iconColor: _getOnColor(successColor),
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
/// Show an error snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showError({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 5),
SnackBarAction? action,
bool showCloseIcon = true,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return _showWithIcon(
context: context,
message: message,
icon: Icons.error_outline,
backgroundColor: colorScheme.error,
textColor: colorScheme.onError,
iconColor: colorScheme.onError,
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
/// Show a warning snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showWarning({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final warningColor = _getWarningColor(theme);
return _showWithIcon(
context: context,
message: message,
icon: Icons.warning_outlined,
backgroundColor: warningColor,
textColor: _getOnColor(warningColor),
iconColor: _getOnColor(warningColor),
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
/// Show an info snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showInfo({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final infoColor = _getInfoColor(theme);
return _showWithIcon(
context: context,
message: message,
icon: Icons.info_outline,
backgroundColor: infoColor,
textColor: _getOnColor(infoColor),
iconColor: _getOnColor(infoColor),
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
/// Show a loading snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showLoading({
required BuildContext context,
String message = 'Loading...',
Duration duration = const Duration(seconds: 30),
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final snackBar = SnackBar(
content: Row(
children: [
SizedBox(
width: AppSpacing.iconSM,
height: AppSpacing.iconSM,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onSurfaceVariant,
),
),
),
AppSpacing.horizontalSpaceMD,
Expanded(
child: Text(
message,
style: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
duration: duration,
backgroundColor: colorScheme.surfaceContainerHighest,
margin: _getDefaultMargin(context),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: AppSpacing.radiusSM,
),
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
/// Show an action snackbar with custom button
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showAction({
required BuildContext context,
required String message,
required String actionLabel,
required VoidCallback onActionPressed,
Duration duration = const Duration(seconds: 6),
Color? backgroundColor,
Color? textColor,
Color? actionColor,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return show(
context: context,
message: message,
duration: duration,
backgroundColor: backgroundColor,
textColor: textColor,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
action: SnackBarAction(
label: actionLabel,
onPressed: onActionPressed,
textColor: actionColor ?? colorScheme.inversePrimary,
),
);
}
/// Show an undo snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showUndo({
required BuildContext context,
required String message,
required VoidCallback onUndo,
String undoLabel = 'Undo',
Duration duration = const Duration(seconds: 5),
VoidCallback? onVisible,
}) {
return showAction(
context: context,
message: message,
actionLabel: undoLabel,
onActionPressed: onUndo,
duration: duration,
onVisible: onVisible,
);
}
/// Show a retry snackbar
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showRetry({
required BuildContext context,
String message = 'Something went wrong',
required VoidCallback onRetry,
String retryLabel = 'Retry',
Duration duration = const Duration(seconds: 6),
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return _showWithIcon(
context: context,
message: message,
icon: Icons.refresh,
backgroundColor: colorScheme.errorContainer,
textColor: colorScheme.onErrorContainer,
iconColor: colorScheme.onErrorContainer,
duration: duration,
action: SnackBarAction(
label: retryLabel,
onPressed: onRetry,
textColor: colorScheme.error,
),
onVisible: onVisible,
);
}
/// Show a custom snackbar with icon
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showCustom({
required BuildContext context,
required String message,
IconData? icon,
Color? iconColor,
Color? backgroundColor,
Color? textColor,
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
if (icon != null) {
return _showWithIcon(
context: context,
message: message,
icon: icon,
backgroundColor: backgroundColor,
textColor: textColor,
iconColor: iconColor,
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
return show(
context: context,
message: message,
backgroundColor: backgroundColor,
textColor: textColor,
duration: duration,
action: action,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
}
/// Hide current snackbar
static void hide(BuildContext context) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
}
/// Remove all snackbars
static void removeAll(BuildContext context) {
ScaffoldMessenger.of(context).clearSnackBars();
}
// Helper methods
/// Show snackbar with icon
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> _showWithIcon({
required BuildContext context,
required String message,
required IconData icon,
Color? backgroundColor,
Color? textColor,
Color? iconColor,
Duration duration = const Duration(seconds: 4),
SnackBarAction? action,
bool showCloseIcon = false,
VoidCallback? onVisible,
}) {
final theme = Theme.of(context);
final snackBarTheme = theme.snackBarTheme;
final snackBar = SnackBar(
content: Row(
children: [
Icon(
icon,
size: AppSpacing.iconSM,
color: iconColor ?? textColor,
),
AppSpacing.horizontalSpaceMD,
Expanded(
child: Text(
message,
style: AppTypography.bodyMedium.copyWith(
color: textColor ?? snackBarTheme.contentTextStyle?.color,
),
),
),
],
),
duration: duration,
action: action,
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
elevation: snackBarTheme.elevation,
margin: _getDefaultMargin(context),
shape: snackBarTheme.shape,
behavior: SnackBarBehavior.floating,
showCloseIcon: showCloseIcon,
onVisible: onVisible,
);
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
/// Get default margin for floating snackbars
static EdgeInsets _getDefaultMargin(BuildContext context) {
return const EdgeInsets.all(AppSpacing.md);
}
/// Get success color from theme extension or fallback
static Color _getSuccessColor(ThemeData theme) {
return const Color(0xFF4CAF50); // Material Green 500
}
/// Get warning color from theme extension or fallback
static Color _getWarningColor(ThemeData theme) {
return const Color(0xFFFF9800); // Material Orange 500
}
/// Get info color from theme extension or fallback
static Color _getInfoColor(ThemeData theme) {
return theme.colorScheme.primary;
}
/// Get appropriate "on" color for given background color
static Color _getOnColor(Color backgroundColor) {
// Simple calculation based on luminance
return backgroundColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white;
}
}
/// Custom snackbar widget for more complex layouts
class AppCustomSnackbar extends StatelessWidget {
/// Creates a custom snackbar widget
const AppCustomSnackbar({
super.key,
required this.child,
this.backgroundColor,
this.elevation,
this.margin,
this.shape,
this.behavior = SnackBarBehavior.floating,
this.width,
this.padding,
});
/// The content to display in the snackbar
final Widget child;
/// Background color override
final Color? backgroundColor;
/// Elevation override
final double? elevation;
/// Margin around the snackbar
final EdgeInsets? margin;
/// Shape override
final ShapeBorder? shape;
/// Snackbar behavior
final SnackBarBehavior behavior;
/// Fixed width override
final double? width;
/// Content padding
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final snackBarTheme = theme.snackBarTheme;
Widget content = Container(
width: width,
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
child: child,
);
return SnackBar(
content: content,
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
elevation: elevation ?? snackBarTheme.elevation,
margin: margin ?? const EdgeInsets.all(AppSpacing.md),
shape: shape ?? snackBarTheme.shape,
behavior: behavior,
padding: EdgeInsets.zero, // Remove default padding since we handle it
);
}
/// Show this custom snackbar
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
required BuildContext context,
Duration duration = const Duration(seconds: 4),
}) {
return ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: child,
backgroundColor: backgroundColor,
elevation: elevation,
margin: margin ?? const EdgeInsets.all(AppSpacing.md),
shape: shape,
behavior: behavior,
duration: duration,
padding: EdgeInsets.zero,
),
);
}
}
/// Snackbar with rich content (title, subtitle, actions)
class AppRichSnackbar extends StatelessWidget {
/// Creates a rich snackbar with title, subtitle, and actions
const AppRichSnackbar({
super.key,
required this.title,
this.subtitle,
this.icon,
this.actions,
this.backgroundColor,
this.onTap,
});
/// Main title text
final String title;
/// Optional subtitle text
final String? subtitle;
/// Optional leading icon
final IconData? icon;
/// Optional action widgets
final List<Widget>? actions;
/// Background color override
final Color? backgroundColor;
/// Tap callback for the entire snackbar
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget content = Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: AppSpacing.iconMD,
color: backgroundColor != null
? _getOnColor(backgroundColor!)
: colorScheme.onSurfaceVariant,
),
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.titleSmall.copyWith(
color: backgroundColor != null
? _getOnColor(backgroundColor!)
: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: AppTypography.bodySmall.copyWith(
color: backgroundColor != null
? _getOnColor(backgroundColor!).withOpacity(0.8)
: colorScheme.onSurfaceVariant.withOpacity(0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (actions != null && actions!.isNotEmpty) ...[
AppSpacing.horizontalSpaceSM,
Row(children: actions!),
],
],
);
if (onTap != null) {
content = InkWell(
onTap: onTap,
borderRadius: AppSpacing.radiusSM,
child: content,
);
}
return AppCustomSnackbar(
backgroundColor: backgroundColor,
child: content,
);
}
/// Show this rich snackbar
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
required BuildContext context,
Duration duration = const Duration(seconds: 5),
}) {
return ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: this,
duration: duration,
backgroundColor: Colors.transparent,
elevation: 0,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(AppSpacing.md),
padding: EdgeInsets.zero,
),
);
}
/// Get appropriate "on" color for given background color
static Color _getOnColor(Color backgroundColor) {
return backgroundColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white;
}
}

View File

@@ -0,0 +1,463 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_spacing.dart';
import '../theme/app_typography.dart';
/// Text input field with validation, error handling, and Material 3 styling
///
/// Provides comprehensive form input functionality with built-in validation,
/// accessibility support, and consistent theming.
class AppTextField extends StatefulWidget {
/// Creates a text input field with the specified configuration
const AppTextField({
super.key,
this.controller,
this.initialValue,
this.labelText,
this.hintText,
this.helperText,
this.errorText,
this.prefixIcon,
this.suffixIcon,
this.onChanged,
this.onSubmitted,
this.onTap,
this.validator,
this.enabled = true,
this.readOnly = false,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.focusNode,
this.textCapitalization = TextCapitalization.none,
this.textAlign = TextAlign.start,
this.style,
this.filled,
this.fillColor,
this.borderRadius,
this.contentPadding,
this.semanticLabel,
});
/// Controls the text being edited
final TextEditingController? controller;
/// Initial value for the field (if no controller provided)
final String? initialValue;
/// Label text displayed above the field
final String? labelText;
/// Hint text displayed when field is empty
final String? hintText;
/// Helper text displayed below the field
final String? helperText;
/// Error text displayed below the field (overrides helper text)
final String? errorText;
/// Icon displayed at the start of the field
final Widget? prefixIcon;
/// Icon displayed at the end of the field
final Widget? suffixIcon;
/// Called when the field value changes
final ValueChanged<String>? onChanged;
/// Called when the field is submitted
final ValueChanged<String>? onSubmitted;
/// Called when the field is tapped
final VoidCallback? onTap;
/// Validator function for form validation
final FormFieldValidator<String>? validator;
/// Whether the field is enabled
final bool enabled;
/// Whether the field is read-only
final bool readOnly;
/// Whether to obscure the text (for passwords)
final bool obscureText;
/// Whether to enable autocorrect
final bool autocorrect;
/// Whether to enable input suggestions
final bool enableSuggestions;
/// Maximum number of lines
final int? maxLines;
/// Minimum number of lines
final int? minLines;
/// Maximum character length
final int? maxLength;
/// Keyboard type for input
final TextInputType? keyboardType;
/// Text input action for the keyboard
final TextInputAction? textInputAction;
/// Input formatters to apply
final List<TextInputFormatter>? inputFormatters;
/// Focus node for the field
final FocusNode? focusNode;
/// Text capitalization behavior
final TextCapitalization textCapitalization;
/// Text alignment within the field
final TextAlign textAlign;
/// Text style override
final TextStyle? style;
/// Whether the field should be filled
final bool? filled;
/// Fill color override
final Color? fillColor;
/// Border radius override
final BorderRadius? borderRadius;
/// Content padding override
final EdgeInsets? contentPadding;
/// Semantic label for accessibility
final String? semanticLabel;
@override
State<AppTextField> createState() => _AppTextFieldState();
}
class _AppTextFieldState extends State<AppTextField> {
late TextEditingController _controller;
late FocusNode _focusNode;
bool _obscureText = false;
bool _isFocused = false;
@override
void initState() {
super.initState();
_controller = widget.controller ?? TextEditingController(text: widget.initialValue);
_focusNode = widget.focusNode ?? FocusNode();
_obscureText = widget.obscureText;
_focusNode.addListener(_onFocusChange);
}
@override
void dispose() {
if (widget.controller == null) {
_controller.dispose();
}
if (widget.focusNode == null) {
_focusNode.dispose();
}
super.dispose();
}
void _onFocusChange() {
setState(() {
_isFocused = _focusNode.hasFocus;
});
}
void _toggleObscureText() {
setState(() {
_obscureText = !_obscureText;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Build suffix icon with password visibility toggle if needed
Widget? suffixIcon = widget.suffixIcon;
if (widget.obscureText) {
suffixIcon = IconButton(
icon: Icon(
_obscureText ? Icons.visibility : Icons.visibility_off,
size: AppSpacing.iconMD,
),
onPressed: _toggleObscureText,
tooltip: _obscureText ? 'Show password' : 'Hide password',
);
}
// Create input decoration
final inputDecoration = InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.prefixIcon,
suffixIcon: suffixIcon,
filled: widget.filled ?? theme.inputDecorationTheme.filled,
fillColor: widget.fillColor ??
(widget.enabled
? theme.inputDecorationTheme.fillColor
: colorScheme.surface.withOpacity(0.1)),
contentPadding: widget.contentPadding ??
theme.inputDecorationTheme.contentPadding,
border: _createBorder(theme, null),
enabledBorder: _createBorder(theme, colorScheme.outline),
focusedBorder: _createBorder(theme, colorScheme.primary),
errorBorder: _createBorder(theme, colorScheme.error),
focusedErrorBorder: _createBorder(theme, colorScheme.error),
disabledBorder: _createBorder(theme, colorScheme.outline.withOpacity(0.38)),
labelStyle: theme.inputDecorationTheme.labelStyle?.copyWith(
color: _getLabelColor(theme, colorScheme),
),
hintStyle: widget.style ?? theme.inputDecorationTheme.hintStyle,
errorStyle: theme.inputDecorationTheme.errorStyle,
helperStyle: theme.inputDecorationTheme.helperStyle,
counterStyle: AppTypography.bodySmall.copyWith(
color: colorScheme.onSurfaceVariant,
),
);
// Create the text field
Widget textField = TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: inputDecoration,
enabled: widget.enabled,
readOnly: widget.readOnly,
obscureText: _obscureText,
autocorrect: widget.autocorrect,
enableSuggestions: widget.enableSuggestions,
maxLines: widget.maxLines,
minLines: widget.minLines,
maxLength: widget.maxLength,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
inputFormatters: widget.inputFormatters,
textCapitalization: widget.textCapitalization,
textAlign: widget.textAlign,
style: widget.style ?? theme.inputDecorationTheme.labelStyle,
validator: widget.validator,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmitted,
onTap: widget.onTap,
);
// Add semantic label if provided
if (widget.semanticLabel != null) {
textField = Semantics(
label: widget.semanticLabel,
child: textField,
);
}
return textField;
}
/// Create input border with custom styling
InputBorder _createBorder(ThemeData theme, Color? borderColor) {
return OutlineInputBorder(
borderRadius: widget.borderRadius ?? AppSpacing.fieldRadius,
borderSide: BorderSide(
color: borderColor ?? Colors.transparent,
width: _isFocused ? AppSpacing.borderWidthThick : AppSpacing.borderWidth,
),
);
}
/// Get appropriate label color based on state
Color _getLabelColor(ThemeData theme, ColorScheme colorScheme) {
if (!widget.enabled) {
return colorScheme.onSurface.withOpacity(0.38);
}
if (widget.errorText != null) {
return colorScheme.error;
}
if (_isFocused) {
return colorScheme.primary;
}
return colorScheme.onSurfaceVariant;
}
}
/// Email validation text field
class AppEmailField extends StatelessWidget {
const AppEmailField({
super.key,
this.controller,
this.labelText = 'Email',
this.hintText = 'Enter your email address',
this.onChanged,
this.validator,
this.enabled = true,
this.readOnly = false,
});
final TextEditingController? controller;
final String labelText;
final String hintText;
final ValueChanged<String>? onChanged;
final FormFieldValidator<String>? validator;
final bool enabled;
final bool readOnly;
@override
Widget build(BuildContext context) {
return AppTextField(
controller: controller,
labelText: labelText,
hintText: hintText,
prefixIcon: const Icon(Icons.email_outlined),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
onChanged: onChanged,
validator: validator ?? _defaultEmailValidator,
enabled: enabled,
readOnly: readOnly,
semanticLabel: 'Email address input field',
);
}
String? _defaultEmailValidator(String? value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return 'Enter a valid email address';
}
return null;
}
}
/// Password validation text field
class AppPasswordField extends StatelessWidget {
const AppPasswordField({
super.key,
this.controller,
this.labelText = 'Password',
this.hintText = 'Enter your password',
this.onChanged,
this.validator,
this.enabled = true,
this.readOnly = false,
this.requireStrong = false,
});
final TextEditingController? controller;
final String labelText;
final String hintText;
final ValueChanged<String>? onChanged;
final FormFieldValidator<String>? validator;
final bool enabled;
final bool readOnly;
final bool requireStrong;
@override
Widget build(BuildContext context) {
return AppTextField(
controller: controller,
labelText: labelText,
hintText: hintText,
prefixIcon: const Icon(Icons.lock_outline),
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onChanged: onChanged,
validator: validator ?? (requireStrong ? _strongPasswordValidator : _defaultPasswordValidator),
enabled: enabled,
readOnly: readOnly,
semanticLabel: 'Password input field',
);
}
String? _defaultPasswordValidator(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
}
String? _strongPasswordValidator(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)').hasMatch(value)) {
return 'Password must contain uppercase, lowercase, and number';
}
return null;
}
}
/// Search text field with built-in search functionality
class AppSearchField extends StatelessWidget {
const AppSearchField({
super.key,
this.controller,
this.hintText = 'Search...',
this.onChanged,
this.onSubmitted,
this.onClear,
this.enabled = true,
this.autofocus = false,
});
final TextEditingController? controller;
final String hintText;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onClear;
final bool enabled;
final bool autofocus;
@override
Widget build(BuildContext context) {
return AppTextField(
controller: controller,
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: controller?.text.isNotEmpty == true
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller?.clear();
onClear?.call();
},
tooltip: 'Clear search',
)
: null,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
onChanged: onChanged,
onSubmitted: onSubmitted,
enabled: enabled,
semanticLabel: 'Search input field',
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
/// A customizable error display widget
class AppErrorWidget extends StatelessWidget {
final String message;
final String? title;
final VoidCallback? onRetry;
final IconData? icon;
const AppErrorWidget({
super.key,
required this.message,
this.title,
this.onRetry,
this.icon,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
if (title != null) ...[
Text(
title!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
],
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(120, 40),
),
),
],
],
),
),
);
}
}
/// A compact error widget for inline use
class InlineErrorWidget extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const InlineErrorWidget({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
if (onRetry != null) ...[
const SizedBox(width: 8),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
minimumSize: const Size(60, 32),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: Text(
'Retry',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
/// A customizable loading widget
class LoadingWidget extends StatelessWidget {
final String? message;
final double size;
final Color? color;
const LoadingWidget({
super.key,
this.message,
this.size = 24.0,
this.color,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
color: color ?? Theme.of(context).colorScheme.primary,
strokeWidth: 3.0,
),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}
/// A small loading indicator for buttons
class SmallLoadingIndicator extends StatelessWidget {
final Color? color;
final double size;
const SmallLoadingIndicator({
super.key,
this.color,
this.size = 16.0,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
color: color ?? Theme.of(context).colorScheme.onPrimary,
strokeWidth: 2.0,
),
);
}
}

View File

@@ -0,0 +1,3 @@
// Barrel export file for core widgets
export 'error_widget.dart';
export 'loading_widget.dart';