init cc
This commit is contained in:
351
lib/core/widgets/app_button.dart
Normal file
351
lib/core/widgets/app_button.dart
Normal 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,
|
||||
}
|
||||
538
lib/core/widgets/app_card.dart
Normal file
538
lib/core/widgets/app_card.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
696
lib/core/widgets/app_dialog.dart
Normal file
696
lib/core/widgets/app_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
501
lib/core/widgets/app_empty_state.dart
Normal file
501
lib/core/widgets/app_empty_state.dart
Normal 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,
|
||||
}
|
||||
579
lib/core/widgets/app_error_widget.dart
Normal file
579
lib/core/widgets/app_error_widget.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
529
lib/core/widgets/app_loading_indicator.dart
Normal file
529
lib/core/widgets/app_loading_indicator.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
621
lib/core/widgets/app_snackbar.dart
Normal file
621
lib/core/widgets/app_snackbar.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
463
lib/core/widgets/app_text_field.dart
Normal file
463
lib/core/widgets/app_text_field.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/core/widgets/error_widget.dart
Normal file
127
lib/core/widgets/error_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/loading_widget.dart
Normal file
69
lib/core/widgets/loading_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/core/widgets/widgets.dart
Normal file
3
lib/core/widgets/widgets.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
// Barrel export file for core widgets
|
||||
export 'error_widget.dart';
|
||||
export 'loading_widget.dart';
|
||||
Reference in New Issue
Block a user