diff --git a/lib/screens/3_home/home_screen.dart b/lib/screens/3_home/home_screen.dart index 249e3b1..a5c840c 100644 --- a/lib/screens/3_home/home_screen.dart +++ b/lib/screens/3_home/home_screen.dart @@ -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 { 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 { 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 { } } -/// 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(); diff --git a/lib/widgets/common/animated_action_button.dart b/lib/widgets/common/animated_action_button.dart new file mode 100644 index 0000000..0967a79 --- /dev/null +++ b/lib/widgets/common/animated_action_button.dart @@ -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 createState() => _AnimatedActionButtonState(); +} + +class _AnimatedActionButtonState extends State + with SingleTickerProviderStateMixin { + /// Controller para animaciones coordinadas + late AnimationController _controller; + + /// Animación de escala + late Animation _scaleAnimation; + + /// Animación de offset de sombra + late Animation _shadowOffsetAnimation; + + /// Animación de blur de sombra + late Animation _shadowBlurAnimation; + + /// Animación de opacidad de sombra + late Animation _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( + 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( + begin: normalOffset, + end: pressedOffset, + ).animate(CurvedAnimation(parent: _controller, curve: curve)); + + _shadowBlurAnimation = Tween( + begin: normalBlur, + end: pressedBlur, + ).animate(CurvedAnimation(parent: _controller, curve: curve)); + + _shadowOpacityAnimation = Tween( + 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, + ), + ), + ], + ), + ), + ), + ); + } +}