fill
This commit is contained in:
349
lib/core/widgets/custom_button.dart
Normal file
349
lib/core/widgets/custom_button.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Custom button widget with loading state and consistent styling
|
||||
///
|
||||
/// This widget provides a reusable button component with:
|
||||
/// - Loading indicator support
|
||||
/// - Disabled state
|
||||
/// - Customizable colors, icons, and text
|
||||
/// - Consistent padding and styling
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// CustomButton(
|
||||
/// text: 'Login',
|
||||
/// onPressed: _handleLogin,
|
||||
/// isLoading: _isLoading,
|
||||
/// )
|
||||
///
|
||||
/// CustomButton.outlined(
|
||||
/// text: 'Cancel',
|
||||
/// onPressed: _handleCancel,
|
||||
/// )
|
||||
///
|
||||
/// CustomButton.text(
|
||||
/// text: 'Skip',
|
||||
/// onPressed: _handleSkip,
|
||||
/// )
|
||||
/// ```
|
||||
class CustomButton extends StatelessWidget {
|
||||
/// Button text
|
||||
final String text;
|
||||
|
||||
/// Callback when button is pressed
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Whether the button is in loading state
|
||||
final bool isLoading;
|
||||
|
||||
/// Optional icon to display before text
|
||||
final IconData? icon;
|
||||
|
||||
/// Button style variant
|
||||
final ButtonStyle? style;
|
||||
|
||||
/// Whether this is an outlined button
|
||||
final bool isOutlined;
|
||||
|
||||
/// Whether this is a text button
|
||||
final bool isTextButton;
|
||||
|
||||
/// Minimum button width (null for full width)
|
||||
final double? minWidth;
|
||||
|
||||
/// Minimum button height
|
||||
final double? minHeight;
|
||||
|
||||
/// Background color (only for elevated buttons)
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Foreground/text color
|
||||
final Color? foregroundColor;
|
||||
|
||||
/// Border color (only for outlined buttons)
|
||||
final Color? borderColor;
|
||||
|
||||
/// Font size
|
||||
final double? fontSize;
|
||||
|
||||
/// Font weight
|
||||
final FontWeight? fontWeight;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.icon,
|
||||
this.style,
|
||||
this.minWidth,
|
||||
this.minHeight,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
}) : isOutlined = false,
|
||||
isTextButton = false,
|
||||
borderColor = null;
|
||||
|
||||
/// Create an outlined button variant
|
||||
const CustomButton.outlined({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.icon,
|
||||
this.style,
|
||||
this.minWidth,
|
||||
this.minHeight,
|
||||
this.foregroundColor,
|
||||
this.borderColor,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
}) : isOutlined = true,
|
||||
isTextButton = false,
|
||||
backgroundColor = null;
|
||||
|
||||
/// Create a text button variant
|
||||
const CustomButton.text({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.icon,
|
||||
this.style,
|
||||
this.minWidth,
|
||||
this.minHeight,
|
||||
this.foregroundColor,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
}) : isOutlined = false,
|
||||
isTextButton = true,
|
||||
backgroundColor = null,
|
||||
borderColor = null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Determine if button should be disabled
|
||||
final bool isDisabled = onPressed == null || isLoading;
|
||||
|
||||
// Build button content
|
||||
Widget content;
|
||||
if (isLoading) {
|
||||
content = SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
isTextButton
|
||||
? foregroundColor ?? colorScheme.primary
|
||||
: foregroundColor ?? colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (icon != null) {
|
||||
content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
content = Text(text);
|
||||
}
|
||||
|
||||
// Build button style
|
||||
final ButtonStyle buttonStyle = style ??
|
||||
(isTextButton
|
||||
? _buildTextButtonStyle(context)
|
||||
: isOutlined
|
||||
? _buildOutlinedButtonStyle(context)
|
||||
: _buildElevatedButtonStyle(context));
|
||||
|
||||
// Build appropriate button widget
|
||||
if (isTextButton) {
|
||||
return TextButton(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
style: buttonStyle,
|
||||
child: content,
|
||||
);
|
||||
} else if (isOutlined) {
|
||||
return OutlinedButton(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
style: buttonStyle,
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
return ElevatedButton(
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
style: buttonStyle,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build elevated button style
|
||||
ButtonStyle _buildElevatedButtonStyle(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? colorScheme.primary,
|
||||
foregroundColor: foregroundColor ?? colorScheme.onPrimary,
|
||||
minimumSize: Size(
|
||||
minWidth ?? double.infinity,
|
||||
minHeight ?? 48,
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: fontWeight ?? FontWeight.w600,
|
||||
),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build outlined button style
|
||||
ButtonStyle _buildOutlinedButtonStyle(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return OutlinedButton.styleFrom(
|
||||
foregroundColor: foregroundColor ?? colorScheme.primary,
|
||||
side: BorderSide(
|
||||
color: borderColor ?? colorScheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
minimumSize: Size(
|
||||
minWidth ?? double.infinity,
|
||||
minHeight ?? 48,
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: fontWeight ?? FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build text button style
|
||||
ButtonStyle _buildTextButtonStyle(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return TextButton.styleFrom(
|
||||
foregroundColor: foregroundColor ?? colorScheme.primary,
|
||||
minimumSize: Size(
|
||||
minWidth ?? 0,
|
||||
minHeight ?? 48,
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: fontWeight ?? FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon button with loading state
|
||||
class CustomIconButton extends StatelessWidget {
|
||||
/// Icon to display
|
||||
final IconData icon;
|
||||
|
||||
/// Callback when button is pressed
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Whether the button is in loading state
|
||||
final bool isLoading;
|
||||
|
||||
/// Icon size
|
||||
final double? iconSize;
|
||||
|
||||
/// Icon color
|
||||
final Color? color;
|
||||
|
||||
/// Background color
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Tooltip text
|
||||
final String? tooltip;
|
||||
|
||||
const CustomIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.iconSize,
|
||||
this.color,
|
||||
this.backgroundColor,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bool isDisabled = onPressed == null || isLoading;
|
||||
|
||||
Widget button = IconButton(
|
||||
icon: isLoading
|
||||
? SizedBox(
|
||||
height: iconSize ?? 24,
|
||||
width: iconSize ?? 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
color ?? theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
icon,
|
||||
size: iconSize ?? 24,
|
||||
color: color,
|
||||
),
|
||||
onPressed: isDisabled ? null : onPressed,
|
||||
style: backgroundColor != null
|
||||
? IconButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
if (tooltip != null) {
|
||||
return Tooltip(
|
||||
message: tooltip!,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
338
lib/core/widgets/loading_indicator.dart
Normal file
338
lib/core/widgets/loading_indicator.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable loading indicator widget
|
||||
///
|
||||
/// Provides different loading indicator variants:
|
||||
/// - Circular (default)
|
||||
/// - Linear
|
||||
/// - Overlay (full screen with backdrop)
|
||||
/// - With message
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Simple circular indicator
|
||||
/// LoadingIndicator()
|
||||
///
|
||||
/// // With custom size and color
|
||||
/// LoadingIndicator(
|
||||
/// size: 50,
|
||||
/// color: Colors.blue,
|
||||
/// )
|
||||
///
|
||||
/// // Linear indicator
|
||||
/// LoadingIndicator.linear()
|
||||
///
|
||||
/// // Full screen overlay
|
||||
/// LoadingIndicator.overlay(
|
||||
/// message: 'Loading data...',
|
||||
/// )
|
||||
///
|
||||
/// // Centered with message
|
||||
/// LoadingIndicator.withMessage(
|
||||
/// message: 'Please wait...',
|
||||
/// )
|
||||
/// ```
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// Size of the loading indicator
|
||||
final double? size;
|
||||
|
||||
/// Color of the loading indicator
|
||||
final Color? color;
|
||||
|
||||
/// Stroke width for circular indicator
|
||||
final double strokeWidth;
|
||||
|
||||
/// Whether to use linear progress indicator
|
||||
final bool isLinear;
|
||||
|
||||
/// Optional loading message
|
||||
final String? message;
|
||||
|
||||
/// Text style for the message
|
||||
final TextStyle? messageStyle;
|
||||
|
||||
/// Spacing between indicator and message
|
||||
final double messageSpacing;
|
||||
|
||||
const LoadingIndicator({
|
||||
super.key,
|
||||
this.size,
|
||||
this.color,
|
||||
this.strokeWidth = 4.0,
|
||||
this.message,
|
||||
this.messageStyle,
|
||||
this.messageSpacing = 16.0,
|
||||
}) : isLinear = false;
|
||||
|
||||
/// Create a linear loading indicator
|
||||
const LoadingIndicator.linear({
|
||||
super.key,
|
||||
this.color,
|
||||
this.message,
|
||||
this.messageStyle,
|
||||
this.messageSpacing = 16.0,
|
||||
}) : isLinear = true,
|
||||
size = null,
|
||||
strokeWidth = 4.0;
|
||||
|
||||
/// Create a full-screen loading overlay
|
||||
static Widget overlay({
|
||||
String? message,
|
||||
Color? backgroundColor,
|
||||
Color? indicatorColor,
|
||||
TextStyle? messageStyle,
|
||||
}) {
|
||||
return _LoadingOverlay(
|
||||
message: message,
|
||||
backgroundColor: backgroundColor,
|
||||
indicatorColor: indicatorColor,
|
||||
messageStyle: messageStyle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a loading indicator with a message below it
|
||||
static Widget withMessage({
|
||||
required String message,
|
||||
double size = 40,
|
||||
Color? color,
|
||||
TextStyle? messageStyle,
|
||||
double spacing = 16.0,
|
||||
}) {
|
||||
return LoadingIndicator(
|
||||
size: size,
|
||||
color: color,
|
||||
message: message,
|
||||
messageStyle: messageStyle,
|
||||
messageSpacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final indicatorColor = color ?? theme.colorScheme.primary;
|
||||
|
||||
Widget indicator;
|
||||
if (isLinear) {
|
||||
indicator = LinearProgressIndicator(
|
||||
color: indicatorColor,
|
||||
backgroundColor: indicatorColor.withOpacity(0.1),
|
||||
);
|
||||
} else {
|
||||
indicator = SizedBox(
|
||||
width: size ?? 40,
|
||||
height: size ?? 40,
|
||||
child: CircularProgressIndicator(
|
||||
color: indicatorColor,
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If there's no message, return just the indicator
|
||||
if (message == null) {
|
||||
return indicator;
|
||||
}
|
||||
|
||||
// If there's a message, wrap in column
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
indicator,
|
||||
SizedBox(height: messageSpacing),
|
||||
Text(
|
||||
message!,
|
||||
style: messageStyle ??
|
||||
theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-screen loading overlay
|
||||
class _LoadingOverlay extends StatelessWidget {
|
||||
final String? message;
|
||||
final Color? backgroundColor;
|
||||
final Color? indicatorColor;
|
||||
final TextStyle? messageStyle;
|
||||
|
||||
const _LoadingOverlay({
|
||||
this.message,
|
||||
this.backgroundColor,
|
||||
this.indicatorColor,
|
||||
this.messageStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
color: backgroundColor ?? Colors.black.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: CircularProgressIndicator(
|
||||
color: indicatorColor ?? theme.colorScheme.primary,
|
||||
strokeWidth: 4,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: Text(
|
||||
message!,
|
||||
style: messageStyle ?? theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shimmer loading effect for list items
|
||||
class ShimmerLoading extends StatefulWidget {
|
||||
/// Width of the shimmer container
|
||||
final double? width;
|
||||
|
||||
/// Height of the shimmer container
|
||||
final double height;
|
||||
|
||||
/// Border radius
|
||||
final double borderRadius;
|
||||
|
||||
/// Base color
|
||||
final Color? baseColor;
|
||||
|
||||
/// Highlight color
|
||||
final Color? highlightColor;
|
||||
|
||||
const ShimmerLoading({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.borderRadius = 4,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShimmerLoading> createState() => _ShimmerLoadingState();
|
||||
}
|
||||
|
||||
class _ShimmerLoadingState extends State<ShimmerLoading>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat();
|
||||
|
||||
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final baseColor = widget.baseColor ?? theme.colorScheme.surfaceVariant;
|
||||
final highlightColor =
|
||||
widget.highlightColor ?? theme.colorScheme.surface;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: [
|
||||
0.0,
|
||||
_animation.value,
|
||||
1.0,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading state for list items
|
||||
class ListLoadingIndicator extends StatelessWidget {
|
||||
/// Number of shimmer items to show
|
||||
final int itemCount;
|
||||
|
||||
/// Height of each item
|
||||
final double itemHeight;
|
||||
|
||||
/// Spacing between items
|
||||
final double spacing;
|
||||
|
||||
const ListLoadingIndicator({
|
||||
super.key,
|
||||
this.itemCount = 5,
|
||||
this.itemHeight = 80,
|
||||
this.spacing = 12,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: itemCount,
|
||||
separatorBuilder: (context, index) => SizedBox(height: spacing),
|
||||
itemBuilder: (context, index) => ShimmerLoading(
|
||||
height: itemHeight,
|
||||
width: double.infinity,
|
||||
borderRadius: 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user