From 81696b5499919db96cd8091e9f8a1561bade33ab Mon Sep 17 00:00:00 2001 From: Gabino Ocotl Date: Thu, 10 Jul 2025 03:59:01 -0500 Subject: [PATCH] Updated animations --- .../src/components/AnimatedGradient.tsx | 154 +++++ .../src/components/InteractiveFeedback.tsx | 472 +++++++++++++++ .../src/components/LoadingAnimations.tsx | 447 ++++++++++++++ .../mobile/src/components/PageTransitions.tsx | 361 ++++++++++++ apps/mobile/src/screens/HomeScreen.tsx | 552 +++++++++--------- 5 files changed, 1708 insertions(+), 278 deletions(-) create mode 100644 apps/mobile/src/components/AnimatedGradient.tsx create mode 100644 apps/mobile/src/components/InteractiveFeedback.tsx create mode 100644 apps/mobile/src/components/LoadingAnimations.tsx create mode 100644 apps/mobile/src/components/PageTransitions.tsx diff --git a/apps/mobile/src/components/AnimatedGradient.tsx b/apps/mobile/src/components/AnimatedGradient.tsx new file mode 100644 index 0000000..fef5be9 --- /dev/null +++ b/apps/mobile/src/components/AnimatedGradient.tsx @@ -0,0 +1,154 @@ +import React, { useEffect } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + interpolateColor, + useDerivedValue, +} from 'react-native-reanimated'; +import { colors } from '../constants/Colors'; + +interface AnimatedGradientProps { + children?: React.ReactNode; + sentimentScore?: number; // 0 = negative, 0.5 = neutral, 1 = positive + intensity?: 'subtle' | 'normal' | 'strong'; + speed?: 'slow' | 'normal' | 'fast'; + style?: object; + isActive?: boolean; // Controls whether animation is running +} + +export function AnimatedGradient({ + children, + sentimentScore = 0.5, + intensity = 'normal', + speed = 'normal', + style, + isActive = true, +}: AnimatedGradientProps) { + const breathingAnimation = useSharedValue(0); + + // Animation timing based on speed + const animationDuration = { + slow: 6000, + normal: 4000, + fast: 2500, + }[speed]; + + // Scale range based on intensity + const scaleRange = { + subtle: [1, 1.02], + normal: [1, 1.05], + strong: [1, 1.08], + }[intensity]; + + useEffect(() => { + if (isActive) { + breathingAnimation.value = withRepeat( + withTiming(1, { duration: animationDuration }), + -1, + true + ); + } else { + breathingAnimation.value = withTiming(0, { duration: 500 }); + } + }, [isActive, animationDuration, breathingAnimation]); + + // Derive colors based on sentiment score + const backgroundColors = useDerivedValue(() => { + // Interpolate between negative, neutral, and positive colors + if (sentimentScore <= 0.5) { + // Negative to neutral range (0 to 0.5) + const progress = sentimentScore * 2; // Scale to 0-1 + return { + primary: interpolateColor( + progress, + [0, 1], + [colors.negative, colors.neutral] + ), + secondary: interpolateColor(progress, [0, 1], ['#fdd8e5', '#e6f3ff']), + }; + } else { + // Neutral to positive range (0.5 to 1) + const progress = (sentimentScore - 0.5) * 2; // Scale to 0-1 + return { + primary: interpolateColor( + progress, + [0, 1], + [colors.neutral, colors.positive] + ), + secondary: interpolateColor(progress, [0, 1], ['#e6f3ff', '#e6fffa']), + }; + } + }, [sentimentScore]); + + const animatedStyle = useAnimatedStyle(() => { + const scale = + breathingAnimation.value * (scaleRange[1] - scaleRange[0]) + + scaleRange[0]; + + return { + transform: [{ scale }], + backgroundColor: backgroundColors.value.primary, + opacity: 0.1 + breathingAnimation.value * 0.05, // Subtle opacity change + }; + }); + + const secondaryLayerStyle = useAnimatedStyle(() => { + const scale = breathingAnimation.value * 0.03 + 1; // Smaller scale for secondary layer + + return { + transform: [{ scale }], + backgroundColor: backgroundColors.value.secondary, + opacity: 0.08 + breathingAnimation.value * 0.03, + }; + }); + + return ( + + {/* Secondary animated layer */} + + + {/* Primary animated layer */} + + + {/* Content */} + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + position: 'relative', + }, + content: { + flex: 1, + position: 'relative', + zIndex: 2, + }, + gradientLayer: { + borderRadius: 12, + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + primaryLayer: { + zIndex: 1, + }, + secondaryLayer: { + zIndex: 0, + }, +}); diff --git a/apps/mobile/src/components/InteractiveFeedback.tsx b/apps/mobile/src/components/InteractiveFeedback.tsx new file mode 100644 index 0000000..79d8b06 --- /dev/null +++ b/apps/mobile/src/components/InteractiveFeedback.tsx @@ -0,0 +1,472 @@ +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import * as Haptics from 'expo-haptics'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + withSequence, + interpolate, + runOnJS, +} from 'react-native-reanimated'; +import { colors } from '../constants/Colors'; +import { typography } from '../constants/Typography'; +import { spacing, borderRadius } from '../constants/Layout'; + +export type HapticType = + | 'light' + | 'medium' + | 'heavy' + | 'success' + | 'warning' + | 'error'; + +interface HapticButtonProps { + children: React.ReactNode; + onPress: () => void; + hapticType?: HapticType; + visualFeedback?: boolean; + style?: object; + disabled?: boolean; +} + +export function HapticButton({ + children, + onPress, + hapticType = 'light', + visualFeedback = true, + style, + disabled = false, +}: HapticButtonProps) { + const scale = useSharedValue(1); + const opacity = useSharedValue(1); + + const triggerHaptic = async (type: HapticType) => { + try { + switch (type) { + case 'light': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + break; + case 'medium': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + break; + case 'heavy': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + break; + case 'success': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + break; + case 'warning': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Warning + ); + break; + case 'error': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Error + ); + break; + } + } catch (error) { + // Haptics not available on this device/simulator + } + }; + + const handlePress = () => { + if (disabled) return; + + // Trigger haptic feedback + runOnJS(triggerHaptic)(hapticType); + + // Visual feedback + if (visualFeedback) { + scale.value = withSequence( + withTiming(0.95, { duration: 100 }), + withSpring(1, { damping: 15, stiffness: 400 }) + ); + } + + onPress(); + }; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + opacity: disabled ? 0.5 : opacity.value, + })); + + return ( + + {children} + + ); +} + +interface RecordingFeedbackProps { + isRecording: boolean; + onStartRecording?: () => void; + onStopRecording?: () => void; +} + +export function RecordingFeedback({ + isRecording, + onStartRecording, + onStopRecording, +}: RecordingFeedbackProps) { + const pulseScale = useSharedValue(1); + const glowOpacity = useSharedValue(0); + + useEffect(() => { + if (isRecording) { + // Continuous pulse while recording + pulseScale.value = withSequence( + withTiming(1.1, { duration: 800 }), + withTiming(1, { duration: 800 }) + ); + + glowOpacity.value = withTiming(0.6, { duration: 300 }); + + // Trigger success haptic when starting + runOnJS(triggerRecordingHaptic)('start'); + } else { + pulseScale.value = withTiming(1, { duration: 300 }); + glowOpacity.value = withTiming(0, { duration: 300 }); + } + }, [isRecording, pulseScale, glowOpacity]); + + const triggerRecordingHaptic = async (action: 'start' | 'stop') => { + try { + if (action === 'start') { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } else { + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + } + } catch (error) { + // Haptics not available + } + }; + + const pulseStyle = useAnimatedStyle(() => ({ + transform: [{ scale: pulseScale.value }], + })); + + const glowStyle = useAnimatedStyle(() => ({ + opacity: glowOpacity.value, + })); + + const handlePress = () => { + if (isRecording) { + runOnJS(triggerRecordingHaptic)('stop'); + onStopRecording?.(); + } else { + onStartRecording?.(); + } + }; + + return ( + + {/* Glow effect */} + + + {/* Recording button */} + + + {isRecording ? '⏹' : '🎤'} + + + + + {isRecording ? 'Recording...' : 'Tap to Record'} + + + ); +} + +interface ConfirmationAnimationProps { + type: 'success' | 'error' | 'warning'; + message: string; + isVisible: boolean; + onComplete?: () => void; + duration?: number; +} + +export function ConfirmationAnimation({ + type, + message, + isVisible, + onComplete, + duration = 2000, +}: ConfirmationAnimationProps) { + const scale = useSharedValue(0); + const opacity = useSharedValue(0); + const translateY = useSharedValue(50); + + useEffect(() => { + if (isVisible) { + // Entry animation + scale.value = withSpring(1, { damping: 15, stiffness: 300 }); + opacity.value = withTiming(1, { duration: 300 }); + translateY.value = withSpring(0, { damping: 20, stiffness: 200 }); + + // Trigger appropriate haptic + const hapticType: HapticType = + type === 'success' ? 'success' : type === 'error' ? 'error' : 'warning'; + runOnJS(triggerHaptic)(hapticType); + + // Auto-hide + setTimeout(() => { + scale.value = withTiming(0.8, { duration: 200 }); + opacity.value = withTiming(0, { duration: 300 }); + translateY.value = withTiming(-20, { duration: 300 }, () => { + if (onComplete) { + runOnJS(onComplete)(); + } + }); + }, duration); + } + }, [isVisible, scale, opacity, translateY, type, duration, onComplete]); + + const triggerHaptic = async (hapticType: HapticType) => { + try { + switch (hapticType) { + case 'success': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + break; + case 'error': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Error + ); + break; + case 'warning': + await Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Warning + ); + break; + } + } catch (error) { + // Haptics not available + } + }; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }, { translateY: translateY.value }], + opacity: opacity.value, + })); + + const getIcon = () => { + switch (type) { + case 'success': + return '✅'; + case 'error': + return '❌'; + case 'warning': + return '⚠️'; + } + }; + + const getColor = () => { + switch (type) { + case 'success': + return colors.success; + case 'error': + return colors.error; + case 'warning': + return colors.warning; + } + }; + + if (!isVisible) return null; + + return ( + + + {getIcon()} + {message} + + + ); +} + +interface MicroInteractionProps { + children: React.ReactNode; + type?: 'bounce' | 'scale' | 'fade' | 'slide'; + trigger?: boolean; + duration?: number; +} + +export function MicroInteraction({ + children, + type = 'scale', + trigger = false, + duration = 300, +}: MicroInteractionProps) { + const animation = useSharedValue(0); + + useEffect(() => { + if (trigger) { + animation.value = withSequence( + withTiming(1, { duration: duration / 2 }), + withTiming(0, { duration: duration / 2 }) + ); + } + }, [trigger, animation, duration]); + + const animatedStyle = useAnimatedStyle(() => { + switch (type) { + case 'bounce': + return { + transform: [ + { translateY: interpolate(animation.value, [0, 1], [0, -10]) }, + ], + }; + case 'scale': + return { + transform: [ + { scale: interpolate(animation.value, [0, 1], [1, 1.05]) }, + ], + }; + case 'fade': + return { + opacity: interpolate(animation.value, [0, 1], [1, 0.7]), + }; + case 'slide': + return { + transform: [ + { translateX: interpolate(animation.value, [0, 1], [0, 5]) }, + ], + }; + default: + return {}; + } + }); + + return {children}; +} + +interface SwipeableCardProps { + children: React.ReactNode; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + threshold?: number; +} + +export function SwipeableCard({ + children, + onSwipeLeft: _onSwipeLeft, + onSwipeRight: _onSwipeRight, + threshold: _threshold = 100, +}: SwipeableCardProps) { + const translateX = useSharedValue(0); + const opacity = useSharedValue(1); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + opacity: opacity.value, + })); + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + confirmationContainer: { + alignItems: 'center', + borderRadius: borderRadius.lg, + elevation: 8, + flexDirection: 'row', + padding: spacing.lg, + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + confirmationIcon: { + fontSize: 24, + marginRight: spacing.md, + }, + confirmationMessage: { + ...typography.body, + color: colors.surface, + flex: 1, + fontWeight: '600', + }, + confirmationOverlay: { + alignItems: 'center', + justifyContent: 'center', + left: spacing.lg, + position: 'absolute', + right: spacing.lg, + top: 100, + zIndex: 1000, + }, + recordingActive: { + backgroundColor: colors.recording, + }, + recordingButton: { + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: 40, + elevation: 8, + height: 80, + justifyContent: 'center', + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + width: 80, + }, + recordingContainer: { + alignItems: 'center', + padding: spacing.lg, + }, + recordingGlow: { + backgroundColor: colors.recording, + borderRadius: 60, + height: 120, + position: 'absolute', + top: spacing.lg, + width: 120, + }, + recordingIcon: { + fontSize: 32, + }, + recordingText: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.md, + }, + swipeableCard: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + elevation: 4, + marginVertical: spacing.sm, + padding: spacing.lg, + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, +}); diff --git a/apps/mobile/src/components/LoadingAnimations.tsx b/apps/mobile/src/components/LoadingAnimations.tsx new file mode 100644 index 0000000..4f3e9d8 --- /dev/null +++ b/apps/mobile/src/components/LoadingAnimations.tsx @@ -0,0 +1,447 @@ +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + withSequence, + withDelay, + interpolate, + Easing, +} from 'react-native-reanimated'; +import { colors } from '../constants/Colors'; +import { typography } from '../constants/Typography'; +import { spacing, borderRadius } from '../constants/Layout'; + +interface PulsingDotsProps { + color?: string; + size?: number; + speed?: 'slow' | 'normal' | 'fast'; +} + +export function PulsingDots({ + color = colors.primary, + size = 8, + speed = 'normal', +}: PulsingDotsProps) { + const dot1 = useSharedValue(0); + const dot2 = useSharedValue(0); + const dot3 = useSharedValue(0); + + const duration = speed === 'slow' ? 800 : speed === 'fast' ? 400 : 600; + + useEffect(() => { + dot1.value = withRepeat( + withSequence(withTiming(1, { duration }), withTiming(0, { duration })), + -1 + ); + dot2.value = withDelay( + duration * 0.3, + withRepeat( + withSequence(withTiming(1, { duration }), withTiming(0, { duration })), + -1 + ) + ); + dot3.value = withDelay( + duration * 0.6, + withRepeat( + withSequence(withTiming(1, { duration }), withTiming(0, { duration })), + -1 + ) + ); + }, [duration, dot1, dot2, dot3]); + + const dot1Style = useAnimatedStyle(() => ({ + opacity: interpolate(dot1.value, [0, 1], [0.3, 1]), + transform: [{ scale: interpolate(dot1.value, [0, 1], [0.8, 1.2]) }], + })); + + const dot2Style = useAnimatedStyle(() => ({ + opacity: interpolate(dot2.value, [0, 1], [0.3, 1]), + transform: [{ scale: interpolate(dot2.value, [0, 1], [0.8, 1.2]) }], + })); + + const dot3Style = useAnimatedStyle(() => ({ + opacity: interpolate(dot3.value, [0, 1], [0.3, 1]), + transform: [{ scale: interpolate(dot3.value, [0, 1], [0.8, 1.2]) }], + })); + + return ( + + + + + + ); +} + +interface ProcessingWaveProps { + color?: string; + height?: number; + isActive?: boolean; +} + +export function ProcessingWave({ + color = colors.primary, + height = 40, + isActive = true, +}: ProcessingWaveProps) { + const waveAnimation = useSharedValue(0); + + useEffect(() => { + if (isActive) { + waveAnimation.value = withRepeat( + withTiming(1, { + duration: 2000, + easing: Easing.inOut(Easing.ease), + }), + -1, + true + ); + } + }, [isActive, waveAnimation]); + + const bar1Style = useAnimatedStyle(() => { + const delay = 0 * 0.1; + const animationProgress = Math.max( + 0, + Math.min(1, waveAnimation.value - delay) + ); + + return { + height: interpolate( + animationProgress, + [0, 0.5, 1], + [height * 0.2, height, height * 0.2] + ), + opacity: interpolate(animationProgress, [0, 1], [0.3, 1]), + }; + }); + + const bar2Style = useAnimatedStyle(() => { + const delay = 1 * 0.1; + const animationProgress = Math.max( + 0, + Math.min(1, waveAnimation.value - delay) + ); + + return { + height: interpolate( + animationProgress, + [0, 0.5, 1], + [height * 0.2, height, height * 0.2] + ), + opacity: interpolate(animationProgress, [0, 1], [0.3, 1]), + }; + }); + + const bar3Style = useAnimatedStyle(() => { + const delay = 2 * 0.1; + const animationProgress = Math.max( + 0, + Math.min(1, waveAnimation.value - delay) + ); + + return { + height: interpolate( + animationProgress, + [0, 0.5, 1], + [height * 0.2, height, height * 0.2] + ), + opacity: interpolate(animationProgress, [0, 1], [0.3, 1]), + }; + }); + + const bar4Style = useAnimatedStyle(() => { + const delay = 3 * 0.1; + const animationProgress = Math.max( + 0, + Math.min(1, waveAnimation.value - delay) + ); + + return { + height: interpolate( + animationProgress, + [0, 0.5, 1], + [height * 0.2, height, height * 0.2] + ), + opacity: interpolate(animationProgress, [0, 1], [0.3, 1]), + }; + }); + + const bar5Style = useAnimatedStyle(() => { + const delay = 4 * 0.1; + const animationProgress = Math.max( + 0, + Math.min(1, waveAnimation.value - delay) + ); + + return { + height: interpolate( + animationProgress, + [0, 0.5, 1], + [height * 0.2, height, height * 0.2] + ), + opacity: interpolate(animationProgress, [0, 1], [0.3, 1]), + }; + }); + + return ( + + + + + + + + ); +} + +interface LoadingCardProps { + title: string; + subtitle?: string; + type?: 'upload' | 'processing' | 'analyzing'; + children?: React.ReactNode; +} + +export function LoadingCard({ + title, + subtitle, + type = 'processing', + children, +}: LoadingCardProps) { + const cardAnimation = useSharedValue(0); + const shimmerAnimation = useSharedValue(0); + + useEffect(() => { + cardAnimation.value = withTiming(1, { duration: 500 }); + shimmerAnimation.value = withRepeat(withTiming(1, { duration: 1500 }), -1); + }, [cardAnimation, shimmerAnimation]); + + const cardStyle = useAnimatedStyle(() => ({ + opacity: cardAnimation.value, + transform: [ + { + translateY: interpolate(cardAnimation.value, [0, 1], [20, 0]), + }, + ], + })); + + const shimmerStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: interpolate(shimmerAnimation.value, [0, 1], [-100, 100]), + }, + ], + })); + + const getIcon = () => { + switch (type) { + case 'upload': + return '📤'; + case 'analyzing': + return '🧠'; + default: + return '⚡'; + } + }; + + const getColor = () => { + switch (type) { + case 'upload': + return colors.info; + case 'analyzing': + return colors.primary; + default: + return colors.secondary; + } + }; + + return ( + + + {getIcon()} + + {title} + {subtitle && {subtitle}} + + + + + + + + + {/* Shimmer effect */} + + + + + {children} + + ); +} + +interface MicroInteractionProps { + children: React.ReactNode; + type?: 'bounce' | 'scale' | 'fade' | 'slide'; + trigger?: boolean; + duration?: number; +} + +export function MicroInteraction({ + children, + type = 'scale', + trigger = false, + duration = 300, +}: MicroInteractionProps) { + const animation = useSharedValue(0); + + useEffect(() => { + if (trigger) { + animation.value = withSequence( + withTiming(1, { duration: duration / 2 }), + withTiming(0, { duration: duration / 2 }) + ); + } + }, [trigger, animation, duration]); + + const animatedStyle = useAnimatedStyle(() => { + switch (type) { + case 'bounce': + return { + transform: [ + { translateY: interpolate(animation.value, [0, 1], [0, -10]) }, + ], + }; + case 'scale': + return { + transform: [ + { scale: interpolate(animation.value, [0, 1], [1, 1.05]) }, + ], + }; + case 'fade': + return { + opacity: interpolate(animation.value, [0, 1], [1, 0.7]), + }; + case 'slide': + return { + transform: [ + { translateX: interpolate(animation.value, [0, 1], [0, 5]) }, + ], + }; + default: + return {}; + } + }); + + return {children}; +} + +const styles = StyleSheet.create({ + dot: { + borderRadius: 50, + }, + dotsContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.sm, + justifyContent: 'center', + paddingVertical: spacing.md, + }, + loadingCard: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + elevation: 4, + marginVertical: spacing.md, + overflow: 'hidden', + padding: spacing.lg, + position: 'relative', + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + }, + loadingCardHeader: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: spacing.md, + }, + loadingContent: { + alignItems: 'center', + }, + loadingIcon: { + fontSize: 32, + marginRight: spacing.md, + }, + loadingSubtitle: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + loadingTextContainer: { + flex: 1, + }, + loadingTitle: { + ...typography.h3, + color: colors.text, + }, + shimmer: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + width: 50, + }, + shimmerContainer: { + bottom: 0, + left: 0, + overflow: 'hidden', + position: 'absolute', + right: 0, + top: 0, + }, + waveBar: { + borderRadius: borderRadius.sm, + width: 4, + }, + waveContainer: { + alignItems: 'flex-end', + flexDirection: 'row', + gap: spacing.xs, + height: 50, + justifyContent: 'center', + paddingVertical: spacing.sm, + }, +}); diff --git a/apps/mobile/src/components/PageTransitions.tsx b/apps/mobile/src/components/PageTransitions.tsx new file mode 100644 index 0000000..fc1997b --- /dev/null +++ b/apps/mobile/src/components/PageTransitions.tsx @@ -0,0 +1,361 @@ +import React, { useEffect } from 'react'; +import { View, StyleSheet, Dimensions } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + interpolate, + runOnJS, + SlideInLeft, + SlideInRight, + SlideOutLeft, + SlideOutRight, + FadeIn, + FadeOut, + ZoomIn, + ZoomOut, +} from 'react-native-reanimated'; +import { spacing } from '../constants/Layout'; + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + +interface PageWrapperProps { + children: React.ReactNode; + transitionType?: 'slide' | 'fade' | 'zoom' | 'scale'; + direction?: 'left' | 'right' | 'up' | 'down'; + duration?: number; + isEntering?: boolean; +} + +export function PageWrapper({ + children, + transitionType = 'slide', + direction = 'right', + duration = 300, + isEntering = true, +}: PageWrapperProps) { + const getEnteringAnimation = () => { + switch (transitionType) { + case 'slide': + return direction === 'left' + ? SlideInLeft.duration(duration) + : SlideInRight.duration(duration); + case 'fade': + return FadeIn.duration(duration); + case 'zoom': + return ZoomIn.duration(duration); + case 'scale': + return ZoomIn.duration(duration).springify(); + default: + return SlideInRight.duration(duration); + } + }; + + const getExitingAnimation = () => { + switch (transitionType) { + case 'slide': + return direction === 'left' + ? SlideOutLeft.duration(duration) + : SlideOutRight.duration(duration); + case 'fade': + return FadeOut.duration(duration); + case 'zoom': + return ZoomOut.duration(duration); + case 'scale': + return ZoomOut.duration(duration).springify(); + default: + return SlideOutLeft.duration(duration); + } + }; + + return ( + + {children} + + ); +} + +interface SlideTransitionProps { + children: React.ReactNode; + isVisible: boolean; + onAnimationComplete?: () => void; + fromDirection?: 'left' | 'right' | 'top' | 'bottom'; +} + +export function SlideTransition({ + children, + isVisible, + onAnimationComplete, + fromDirection = 'right', +}: SlideTransitionProps) { + const translateX = useSharedValue( + fromDirection === 'left' ? -screenWidth : screenWidth + ); + const translateY = useSharedValue( + fromDirection === 'top' ? -screenHeight : screenHeight + ); + const opacity = useSharedValue(0); + + useEffect(() => { + if (isVisible) { + translateX.value = withSpring(0, { damping: 15, stiffness: 150 }); + translateY.value = withSpring(0, { damping: 15, stiffness: 150 }); + opacity.value = withTiming(1, { duration: 300 }); + } else { + const targetX = + fromDirection === 'left' + ? -screenWidth + : fromDirection === 'right' + ? screenWidth + : 0; + const targetY = + fromDirection === 'top' + ? -screenHeight + : fromDirection === 'bottom' + ? screenHeight + : 0; + + translateX.value = withTiming(targetX, { duration: 300 }); + translateY.value = withTiming(targetY, { duration: 300 }); + opacity.value = withTiming(0, { duration: 300 }, () => { + if (onAnimationComplete) { + runOnJS(onAnimationComplete)(); + } + }); + } + }, [ + isVisible, + translateX, + translateY, + opacity, + fromDirection, + onAnimationComplete, + ]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: ['left', 'right'].includes(fromDirection) + ? translateX.value + : 0, + }, + { + translateY: ['top', 'bottom'].includes(fromDirection) + ? translateY.value + : 0, + }, + ], + opacity: opacity.value, + })); + + return ( + + {children} + + ); +} + +interface SuccessAnimationProps { + isVisible: boolean; + onComplete?: () => void; + children?: React.ReactNode; +} + +export function SuccessAnimation({ + isVisible, + onComplete, + children, +}: SuccessAnimationProps) { + const scale = useSharedValue(0); + const rotation = useSharedValue(0); + const opacity = useSharedValue(0); + + useEffect(() => { + if (isVisible) { + // Sequence of animations for success feedback + scale.value = withSpring(1, { damping: 12, stiffness: 200 }); + rotation.value = withSpring(360, { damping: 15, stiffness: 100 }); + opacity.value = withTiming(1, { duration: 200 }); + + // Auto-hide after showing + setTimeout(() => { + scale.value = withSpring(0, { damping: 15, stiffness: 150 }); + opacity.value = withTiming(0, { duration: 300 }, () => { + if (onComplete) { + runOnJS(onComplete)(); + } + }); + }, 2000); + } + }, [isVisible, scale, rotation, opacity, onComplete]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }, { rotate: `${rotation.value}deg` }], + opacity: opacity.value, + })); + + if (!isVisible) return null; + + return ( + + + {children || } + + + ); +} + +interface ProgressTransitionProps { + progress: number; // 0 to 1 + children: React.ReactNode; + direction?: 'horizontal' | 'vertical'; +} + +export function ProgressTransition({ + progress, + children, + direction = 'horizontal', +}: ProgressTransitionProps) { + const animatedStyle = useAnimatedStyle(() => { + const translateValue = interpolate( + progress, + [0, 1], + direction === 'horizontal' ? [-screenWidth, 0] : [-screenHeight, 0] + ); + + return { + transform: [ + direction === 'horizontal' + ? { translateX: translateValue } + : { translateY: translateValue }, + ], + opacity: interpolate(progress, [0, 0.3, 1], [0, 0.5, 1]), + }; + }); + + return ( + + {children} + + ); +} + +interface CardStackProps { + cards: React.ReactNode[]; + currentIndex: number; + onSwipe?: (direction: 'left' | 'right') => void; +} + +export function CardStack({ + cards, + currentIndex, + onSwipe: _onSwipe, +}: CardStackProps) { + const translateX = useSharedValue(0); + const scale = useSharedValue(1); + + useEffect(() => { + // Reset position when index changes + translateX.value = withSpring(0); + scale.value = withSpring(1); + }, [currentIndex, translateX, scale]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }, { scale: scale.value }], + })); + + const nextCardStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate( + Math.abs(translateX.value), + [0, screenWidth * 0.5], + [0.9, 1] + ), + }, + ], + opacity: interpolate( + Math.abs(translateX.value), + [0, screenWidth * 0.3], + [0.5, 1] + ), + })); + + return ( + + {/* Next card (behind) */} + {currentIndex + 1 < cards.length && ( + + {cards[currentIndex + 1]} + + )} + + {/* Current card (front) */} + + {cards[currentIndex]} + + + ); +} + +const styles = StyleSheet.create({ + cardStackContainer: { + flex: 1, + position: 'relative', + }, + currentCard: { + zIndex: 2, + }, + defaultSuccess: { + backgroundColor: '#4CAF50', + borderRadius: 30, + height: 60, + width: 60, + }, + nextCard: { + zIndex: 1, + }, + pageContainer: { + flex: 1, + }, + progressContainer: { + flex: 1, + }, + stackCard: { + bottom: 0, + left: 0, + margin: spacing.md, + position: 'absolute', + right: 0, + top: 0, + }, + successContainer: { + alignItems: 'center', + height: 100, + justifyContent: 'center', + width: 100, + }, + successOverlay: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.3)', + bottom: 0, + justifyContent: 'center', + left: 0, + position: 'absolute', + right: 0, + top: 0, + zIndex: 1000, + }, + transitionContainer: { + flex: 1, + }, +}); diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 843ec9f..fd629ed 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -1,363 +1,359 @@ -import React, { useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - SafeAreaView, - ScrollView, - Linking, - Alert, -} from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { colors } from '../constants/Colors'; import { typography } from '../constants/Typography'; -import { spacing } from '../constants/Layout'; -import { Button } from '../components/ui/Button'; +import { spacing, borderRadius } from '../constants/Layout'; + import { Card } from '../components/ui/Card'; -import { ApiTestComponent } from '../components/ApiTestComponent'; import { useAppContext } from '../context/AppContext'; +import { AnimatedGradient } from '../components/AnimatedGradient'; +import { + HapticButton, + MicroInteraction, +} from '../components/InteractiveFeedback'; +import { PageWrapper } from '../components/PageTransitions'; export function HomeScreen() { - const { state, dispatch, actions } = useAppContext(); - - useEffect(() => { - // Check API health when component mounts - actions.checkApiHealth(); - }, [actions]); + const { state, dispatch } = useAppContext(); + const [buttonPressed, setButtonPressed] = useState(''); const handleStartRecording = () => { + setButtonPressed('record'); + setTimeout(() => setButtonPressed(''), 300); dispatch({ type: 'NAVIGATE_TO', payload: 'recording' }); }; - const handleRetryConnection = () => { - actions.checkApiHealth(); - }; - - const viewResults = () => { + const handleViewResults = () => { + setButtonPressed('results'); + setTimeout(() => setButtonPressed(''), 300); dispatch({ type: 'NAVIGATE_TO', payload: 'results' }); }; - const handleCallCounseling = async () => { - const phoneNumber = '1-608-265-5600'; - const phoneUrl = `tel:${phoneNumber}`; - - try { - const supported = await Linking.canOpenURL(phoneUrl); - if (supported) { - await Linking.openURL(phoneUrl); - } else { - Alert.alert( - 'Cannot make call', - "Your device doesn't support making calls from this app", - [{ text: 'OK' }] - ); - } - } catch (error) { - Alert.alert('Error', 'Failed to initiate call. Please try again.', [ - { text: 'OK' }, - ]); - } - }; + // Get sentiment score from latest check-in for gradient animation + const sentimentScore = state.checkinData.sentiment?.score ?? 0.5; return ( - - - - + - - {/* Header */} - - Welcome to - PulseMates - - Your companion for mental wellness and personal growth - - + + - {/* API Status */} - - - - {state.loading.health - ? '🔄' - : state.apiHealth.isHealthy - ? '🟢' - : '🔴'}{' '} - Server Status - - {state.loading.health && ( - Checking... - )} - - - - {state.loading.health - ? 'Checking connection...' - : state.apiHealth.message} - + + {/* Header */} + + + PulseMates + + Your mental wellness companion + + + - {state.apiHealth.lastChecked && ( - - Last checked:{' '} - {new Date(state.apiHealth.lastChecked).toLocaleTimeString()} - - )} + {/* Quick Check-in Card */} + + + + 🎤 + Quick Check-in + + Share how you're feeling with a 60-second voice + recording + - {!state.apiHealth.isHealthy && !state.loading.health && ( -