This commit is contained in:
2025-10-28 00:09:46 +07:00
parent 9ebe7c2919
commit de49f564b1
110 changed files with 15392 additions and 3996 deletions

View 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;
}
}

View 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,
),
);
}
}