Files
Sage/lib/features/authentication/presentation/widgets/auth_button.dart
Dani B 501951d3bb feat(01-09): enhance auth components with loading states and error display
AuthForm enhancements:
- Add form-wide error display with dismiss functionality
- Add field-specific error styling with icons
- Auto-clear errors when user starts typing
- Enhanced accessibility with semantic announcements
- Better error positioning and visual hierarchy

AuthButton enhancements:
- Convert to StatefulWidget with animation support
- Add success state with visual feedback
- Enhanced loading states with custom text
- Double-tap prevention for better UX
- Haptic feedback on button press
- Comprehensive accessibility labels and hints
- Smooth visual transitions between states
- Better disabled state handling
2026-01-28 12:46:43 -05:00

352 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
/// Custom authentication button with loading states and variants
class AuthButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final bool isSuccess;
final String? loadingText;
final String? successText;
final AuthButtonVariant variant;
final bool fullWidth;
final Widget? icon;
final Duration successDuration;
const AuthButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.isSuccess = false,
this.loadingText,
this.successText,
this.variant = AuthButtonVariant.primary,
this.fullWidth = true,
this.icon,
this.successDuration = const Duration(seconds: 2),
});
@override
State<AuthButton> createState() => _AuthButtonState();
}
class _AuthButtonState extends State<AuthButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
bool _isPressed = false;
DateTime? _lastPressedTime;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_opacityAnimation = Tween<double>(
begin: 1.0,
end: 0.7,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = _getColors(theme);
final isDisabled = widget.isLoading || widget.onPressed == null;
final displayText = _getDisplayText();
final displayIcon = _getDisplayIcon();
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Semantics(
button: true,
label: _getAccessibilityLabel(),
hint: _getAccessibilityHint(),
child: SizedBox(
width: widget.fullWidth ? double.infinity : null,
height: 48,
child: ElevatedButton(
onPressed: isDisabled ? null : _handlePressed,
style: ElevatedButton.styleFrom(
backgroundColor: _getBackgroundColor(colors),
foregroundColor: _getForegroundColor(colors),
disabledBackgroundColor: colors.disabledBackground,
disabledForegroundColor: colors.disabledForeground,
elevation: widget.variant == AuthButtonVariant.primary ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: widget.variant == AuthButtonVariant.outline
? BorderSide(color: colors.background)
: BorderSide.none,
),
),
child: _buildButtonContent(displayText, displayIcon, colors.foreground, theme),
),
),
),
),
);
},
);
}
Widget _buildButtonContent(String text, Widget? icon, Color foregroundColor, ThemeData theme) {
if (widget.isLoading) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(foregroundColor),
),
),
const SizedBox(width: 12),
Text(
widget.loadingText ?? 'Loading...',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: foregroundColor,
),
),
],
);
}
if (widget.isSuccess) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
color: foregroundColor,
size: 20,
),
const SizedBox(width: 8),
Text(
widget.successText ?? 'Success!',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: foregroundColor,
),
),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
icon!,
const SizedBox(width: 8),
],
Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: foregroundColor,
),
),
],
);
}
String _getDisplayText() {
if (widget.isLoading) {
return widget.loadingText ?? 'Loading...';
}
if (widget.isSuccess) {
return widget.successText ?? 'Success!';
}
return widget.text;
}
Widget? _getDisplayIcon() {
if (widget.isLoading || widget.isSuccess) {
return null; // Loading spinner or checkmark will be shown
}
return widget.icon;
}
Color _getBackgroundColor(_ButtonColors colors) {
if (widget.isSuccess) {
return Theme.of(context).colorScheme.primary;
}
return colors.background;
}
Color _getForegroundColor(_ButtonColors colors) {
if (widget.isSuccess) {
return Theme.of(context).colorScheme.onPrimary;
}
return colors.foreground;
}
String _getAccessibilityLabel() {
if (widget.isLoading) {
return '${widget.loadingText ?? "Loading"} button';
}
if (widget.isSuccess) {
return '${widget.successText ?? "Success"} completed';
}
return widget.text;
}
String _getAccessibilityHint() {
if (widget.isLoading) {
return 'Please wait while operation completes';
}
if (widget.isSuccess) {
return 'Operation completed successfully';
}
return 'Double tap to ${widget.text.toLowerCase()}';
}
void _handlePressed() async {
// Prevent double-tap within 500ms
final now = DateTime.now();
if (_lastPressedTime != null &&
now.difference(_lastPressedTime!).inMilliseconds < 500) {
return;
}
_lastPressedTime = now;
// Provide haptic feedback if available
try {
HapticFeedback.lightImpact();
} catch (e) {
// Haptic feedback not available on all platforms
}
// Animate press
_animationController.forward().then((_) {
_animationController.reverse();
});
// Announce for accessibility
_announceForAccessibility('${widget.text} activated');
// Execute callback
await widget.onPressed?.call();
// Auto-reset success state after duration
if (widget.isSuccess) {
Future.delayed(widget.successDuration, () {
if (mounted) {
// Success state reset should be handled by parent
_announceForAccessibility('Success state completed');
}
});
}
}
void _announceForAccessibility(String message) {
SemanticsService.announce(message, TextDirection.ltr);
}
_ButtonColors _getColors(ThemeData theme) {
switch (variant) {
case AuthButtonVariant.primary:
return _ButtonColors(
background: theme.colorScheme.primary,
foreground: theme.colorScheme.onPrimary,
disabledBackground: theme.colorScheme.primary.withOpacity(0.12),
disabledForeground: theme.colorScheme.onPrimary.withOpacity(0.38),
);
case AuthButtonVariant.secondary:
return _ButtonColors(
background: theme.colorScheme.secondary,
foreground: theme.colorScheme.onSecondary,
disabledBackground: theme.colorScheme.secondary.withOpacity(0.12),
disabledForeground: theme.colorScheme.onSecondary.withOpacity(0.38),
);
case AuthButtonVariant.outline:
return _ButtonColors(
background: Colors.transparent,
foreground: theme.colorScheme.primary,
disabledBackground: Colors.transparent,
disabledForeground: theme.colorScheme.primary.withOpacity(0.38),
);
}
}
}
enum AuthButtonVariant {
primary,
secondary,
outline,
}
_ButtonColors _getColors(ThemeData theme) {
switch (widget.variant) {
case AuthButtonVariant.primary:
return _ButtonColors(
background: theme.colorScheme.primary,
foreground: theme.colorScheme.onPrimary,
disabledBackground: theme.colorScheme.primary.withOpacity(0.12),
disabledForeground: theme.colorScheme.onPrimary.withOpacity(0.38),
);
case AuthButtonVariant.secondary:
return _ButtonColors(
background: theme.colorScheme.secondary,
foreground: theme.colorScheme.onSecondary,
disabledBackground: theme.colorScheme.secondary.withOpacity(0.12),
disabledForeground: theme.colorScheme.onSecondary.withOpacity(0.38),
);
case AuthButtonVariant.outline:
return _ButtonColors(
background: Colors.transparent,
foreground: theme.colorScheme.primary,
disabledBackground: Colors.transparent,
disabledForeground: theme.colorScheme.primary.withOpacity(0.38),
);
}
}
}
class _ButtonColors {
final Color background;
final Color foreground;
final Color disabledBackground;
final Color disabledForeground;
const _ButtonColors({
required this.background,
required this.foreground,
required this.disabledBackground,
required this.disabledForeground,
});
}