feature/animated-action-buttons
This commit is contained in:
@@ -7,6 +7,7 @@ import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/animated_action_button.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
@@ -298,15 +299,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
// Enviar (primero) - flecha diagonal arriba derecha
|
||||
// Enviar (primero) - acción crítica que mueve dinero
|
||||
Expanded(
|
||||
child: _ActionButton(label: 'Enviar ↗', onTap: _showSendOptions),
|
||||
child: AnimatedActionButton(
|
||||
label: 'Enviar ↗',
|
||||
type: ButtonType.criticalAction,
|
||||
onTap: _showSendOptions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
// Recibir (segundo) - flecha diagonal abajo derecha
|
||||
// Recibir (segundo) - acción importante pero segura
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
child: AnimatedActionButton(
|
||||
label: '↘ Recibir',
|
||||
type: ButtonType.primaryAction,
|
||||
onTap: _showReceiveOptions,
|
||||
),
|
||||
),
|
||||
@@ -390,31 +396,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Widget _buildHistoryButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GlassCard(
|
||||
onTap: _showHistoryModal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingLarge,
|
||||
vertical: AppDimensions.paddingMedium + 4,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.history, color: Colors.white, size: 28),
|
||||
SizedBox(width: AppDimensions.paddingSmall),
|
||||
Text(
|
||||
'Historial',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: AnimatedActionButton(
|
||||
label: 'Historial',
|
||||
type: ButtonType.navigation,
|
||||
icon: LucideIcons.history,
|
||||
showIcon: true,
|
||||
onTap: _showHistoryModal,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -429,53 +416,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Botón de acción para el home
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppDimensions.paddingMedium,
|
||||
horizontal: AppDimensions.paddingSmall,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.buttonGradient,
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.cardBorderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal del historial de transacciones
|
||||
class _HistoryModal extends StatelessWidget {
|
||||
const _HistoryModal();
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
|
||||
/// Tipos de botón según la importancia de la acción
|
||||
/// La intensidad del efecto = importancia de la acción
|
||||
enum ButtonType {
|
||||
/// Acciones críticas que mueven dinero (Enviar)
|
||||
/// Efecto más fuerte: heavyImpact, scale 0.95, delay 100ms
|
||||
criticalAction,
|
||||
|
||||
/// Acciones importantes pero seguras (Recibir)
|
||||
/// Efecto medio: mediumImpact, scale 0.97, sin delay
|
||||
primaryAction,
|
||||
|
||||
/// Acciones de navegación (Historial)
|
||||
/// Efecto sutil: lightImpact, scale 0.98, sin delay
|
||||
navigation,
|
||||
}
|
||||
|
||||
/// Botón animado con feedback táctil y visual premium
|
||||
///
|
||||
/// Diferencia la intensidad del efecto según el tipo de acción:
|
||||
/// - [ButtonType.criticalAction]: Para acciones que mueven dinero
|
||||
/// - [ButtonType.primaryAction]: Para acciones importantes pero seguras
|
||||
/// - [ButtonType.navigation]: Para navegación simple
|
||||
///
|
||||
/// Ejemplo de uso:
|
||||
/// ```dart
|
||||
/// AnimatedActionButton(
|
||||
/// label: 'Enviar',
|
||||
/// type: ButtonType.criticalAction,
|
||||
/// onTap: () => _handleSend(),
|
||||
/// )
|
||||
/// ```
|
||||
class AnimatedActionButton extends StatefulWidget {
|
||||
/// Texto del botón
|
||||
final String label;
|
||||
|
||||
/// Callback al tocar (se ejecuta después del micro-delay si aplica)
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Tipo de botón que determina la intensidad del efecto
|
||||
final ButtonType type;
|
||||
|
||||
/// Gradiente opcional (para botones primarios)
|
||||
/// Si es null y backgroundColor también, usa el gradiente por defecto
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Color de fondo opcional (para botones de navegación)
|
||||
/// Se ignora si gradient está definido
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Icono opcional antes del texto
|
||||
final IconData? icon;
|
||||
|
||||
/// Si mostrar el icono (default: false)
|
||||
final bool showIcon;
|
||||
|
||||
/// Ancho del botón (default: expandir al padre)
|
||||
final double? width;
|
||||
|
||||
/// Alto del botón (default: según tipo)
|
||||
final double? height;
|
||||
|
||||
const AnimatedActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.type,
|
||||
this.gradient,
|
||||
this.backgroundColor,
|
||||
this.icon,
|
||||
this.showIcon = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedActionButton> createState() => _AnimatedActionButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedActionButtonState extends State<AnimatedActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Controller para animaciones coordinadas
|
||||
late AnimationController _controller;
|
||||
|
||||
/// Animación de escala
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
/// Animación de offset de sombra
|
||||
late Animation<double> _shadowOffsetAnimation;
|
||||
|
||||
/// Animación de blur de sombra
|
||||
late Animation<double> _shadowBlurAnimation;
|
||||
|
||||
/// Animación de opacidad de sombra
|
||||
late Animation<double> _shadowOpacityAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Duración de la animación: 150ms para sentirse responsivo
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
// Configurar animaciones según el tipo de botón
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
// Curve suave para salida (easeOutCubic da sensación premium)
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
// === ESCALA ===
|
||||
// Intensidad según tipo: crítico se hunde más, navegación casi imperceptible
|
||||
final double targetScale = switch (widget.type) {
|
||||
ButtonType.criticalAction => 0.95, // Se hunde más - acción crítica
|
||||
ButtonType.primaryAction => 0.97, // Se hunde menos
|
||||
ButtonType.navigation => 0.98, // Muy sutil - solo navegación
|
||||
};
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: targetScale,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
// === SOMBRA ===
|
||||
// Los valores de sombra también varían según tipo
|
||||
final (normalOffset, pressedOffset) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (6.0, 2.0),
|
||||
ButtonType.primaryAction => (6.0, 2.0),
|
||||
ButtonType.navigation => (4.0, 1.0),
|
||||
};
|
||||
|
||||
final (normalBlur, pressedBlur) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (12.0, 4.0),
|
||||
ButtonType.primaryAction => (12.0, 4.0),
|
||||
ButtonType.navigation => (8.0, 3.0),
|
||||
};
|
||||
|
||||
final (normalOpacity, pressedOpacity) = switch (widget.type) {
|
||||
ButtonType.criticalAction => (0.4, 0.15),
|
||||
ButtonType.primaryAction => (0.3, 0.1),
|
||||
ButtonType.navigation => (0.2, 0.1),
|
||||
};
|
||||
|
||||
_shadowOffsetAnimation = Tween<double>(
|
||||
begin: normalOffset,
|
||||
end: pressedOffset,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowBlurAnimation = Tween<double>(
|
||||
begin: normalBlur,
|
||||
end: pressedBlur,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
|
||||
_shadowOpacityAnimation = Tween<double>(
|
||||
begin: normalOpacity,
|
||||
end: pressedOpacity,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: curve));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reconfigurar si cambia el tipo
|
||||
if (oldWidget.type != widget.type) {
|
||||
_setupAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Trigger haptic feedback según tipo de acción
|
||||
void _triggerHaptic() {
|
||||
switch (widget.type) {
|
||||
case ButtonType.criticalAction:
|
||||
// Acción crítica - mueve dinero - feedback fuerte
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case ButtonType.primaryAction:
|
||||
// Acción importante pero segura - feedback medio
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case ButtonType.navigation:
|
||||
// Solo navegación - feedback sutil
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay después del tap según tipo
|
||||
/// Solo acciones críticas tienen micro-delay (sensación de "peso")
|
||||
Duration get _callbackDelay {
|
||||
return widget.type == ButtonType.criticalAction
|
||||
? const Duration(milliseconds: 100)
|
||||
: Duration.zero;
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
// Haptic inmediato al tocar
|
||||
_triggerHaptic();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
|
||||
// Ejecutar callback después del delay según tipo
|
||||
Future.delayed(_callbackDelay, () {
|
||||
widget.onTap();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
// Revertir animación sin ejecutar callback
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determinar si es botón con gradiente o con color sólido
|
||||
final bool isGradientButton = widget.type != ButtonType.navigation;
|
||||
|
||||
// Gradiente por defecto para botones primarios
|
||||
final effectiveGradient = widget.gradient ??
|
||||
(isGradientButton
|
||||
? const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: AppColors.buttonGradient,
|
||||
)
|
||||
: null);
|
||||
|
||||
// Color de fondo para botón de navegación (glass effect)
|
||||
final effectiveBackgroundColor = widget.backgroundColor ??
|
||||
(!isGradientButton
|
||||
? AppColors.glassBase.withValues(alpha: AppColors.glassOpacity)
|
||||
: null);
|
||||
|
||||
// Color de sombra según tipo
|
||||
final shadowColor = isGradientButton
|
||||
? AppColors.primaryAction
|
||||
: Colors.black;
|
||||
|
||||
// Altura según tipo
|
||||
final effectiveHeight = widget.height ??
|
||||
(widget.type == ButtonType.navigation
|
||||
? AppDimensions.buttonHeight + 4
|
||||
: AppDimensions.buttonHeight);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: widget.width ?? double.infinity,
|
||||
height: effectiveHeight,
|
||||
decoration: BoxDecoration(
|
||||
// Gradiente o color sólido
|
||||
gradient: effectiveGradient,
|
||||
color: effectiveGradient == null ? effectiveBackgroundColor : null,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonBorderRadius),
|
||||
// Borde para botones de navegación (glass effect)
|
||||
border: !isGradientButton
|
||||
? Border.all(
|
||||
color: Colors.white.withValues(alpha: AppColors.glassBorderOpacity),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
// Sombra animada
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: shadowColor.withValues(alpha: _shadowOpacityAnimation.value),
|
||||
blurRadius: _shadowBlurAnimation.value,
|
||||
offset: Offset(0, _shadowOffsetAnimation.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icono opcional
|
||||
if (widget.showIcon && widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: Colors.white,
|
||||
size: widget.type == ButtonType.navigation ? 28 : 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingSmall),
|
||||
],
|
||||
// Texto
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: widget.type == ButtonType.navigation ? 20 : 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user