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 && (
-
- )}
-
+
+
+ Start Recording
+
+
+
+
+
- {/* API Testing Component - For development/testing */}
-
+ {/* Results Card */}
+ {state.checkinData.sessionId && (
+
+
+
+ 📊
+
+ Latest Results
+
+ View your personalized insights and coaching
+
+
+
- {/* Start Recording Button */}
-
+
+
+
+ View Results
+
+
+
+
+
+ )}
- {!state.apiHealth.isHealthy && !state.loading.health && (
-
- Please ensure the server is running before starting a check-in
-
- )}
+ {/* Info Cards */}
+
+
+
+ 🔒
+ Private & Secure
+
+ Your recordings are processed securely and never shared
+
+
+
- {/* Main Actions */}
-
-
+
+
+ 🧠
+ AI-Powered
+
+ Advanced sentiment analysis and personalized coaching
+
+
+
+
+ {/* Stats Card */}
{state.checkinData.sessionId && (
-
+
+
+ Your Progress
+
+
+ 1
+ Check-ins
+
+
+
+ {state.checkinData.sentiment
+ ? Math.round(state.checkinData.sentiment.score * 100)
+ : '--'}
+ %
+
+ Mood Score
+
+
+
+
)}
-
-
- {/* Recent Activity */}
- {state.checkinData.sessionId && (
-
- Recent Check-in
-
-
- {state.recordingData.timestamp
- ? new Date(
- state.recordingData.timestamp
- ).toLocaleDateString()
- : 'Today'}
-
- {state.checkinData.sentiment && (
-
- Mood: {state.checkinData.sentiment.label} (
- {Math.round(state.checkinData.sentiment.score * 100)}%)
-
- )}
-
-
-
- )}
-
- {/* Quick Tips */}
-
- Daily Wellness Tips
-
- • Take deep breaths when feeling overwhelmed{'\n'}• Share your
- thoughts and feelings regularly{'\n'}• Practice gratitude for
- small moments{'\n'}• Remember that seeking help is a sign of
- strength
-
-
- {/* Crisis Resources */}
-
- Need Immediate Support?
-
- If you're in crisis or having thoughts of self-harm, please
- reach out immediately:
-
-
-
+ {/* Footer */}
+
+
+ Take a moment for yourself. Your mental health matters.
+
-
-
-
-
+
+
+
+
);
}
const styles = StyleSheet.create({
- actionButton: {
- marginBottom: spacing.sm,
- },
- actionsGrid: {
- gap: spacing.md,
- marginTop: spacing.xl,
- },
- container: {
- backgroundColor: colors.background,
- flex: 1,
- },
- content: {
- padding: spacing.lg,
- },
- crisisButton: {
- borderColor: colors.warning,
+ buttonContent: {
+ alignItems: 'center',
+ justifyContent: 'center',
},
- crisisButtons: {
- gap: spacing.sm,
+ buttonText: {
+ ...typography.body,
+ color: colors.surface,
+ fontWeight: '600',
},
- crisisCard: {
- borderColor: colors.warning,
- borderWidth: 2,
- marginTop: spacing.xl,
+ cardContent: {
+ alignItems: 'center',
},
- crisisText: {
+ cardDescription: {
...typography.body,
color: colors.textSecondary,
- lineHeight: 20,
+ marginBottom: spacing.xl,
+ textAlign: 'center',
+ },
+ cardIcon: {
+ fontSize: 48,
marginBottom: spacing.md,
},
- crisisTitle: {
- ...typography.h3,
+ cardTitle: {
+ ...typography.h2,
color: colors.text,
marginBottom: spacing.sm,
- },
- disabledText: {
- ...typography.bodySmall,
- color: colors.textSecondary,
- marginTop: spacing.sm,
textAlign: 'center',
},
- greeting: {
+ container: {
+ flex: 1,
+ },
+ footer: {
+ marginHorizontal: spacing.lg,
+ marginTop: spacing.lg,
+ paddingVertical: spacing.lg,
+ },
+ footerText: {
...typography.body,
color: colors.textSecondary,
- marginBottom: spacing.xs,
+ fontStyle: 'italic',
+ textAlign: 'center',
},
header: {
alignItems: 'center',
- marginBottom: spacing.xxl,
- paddingVertical: spacing.xl,
+ paddingBottom: spacing.lg,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xl,
},
- lastCheckedText: {
- ...typography.bodySmall,
- color: colors.textSecondary,
- marginTop: spacing.sm,
+ infoCard: {
+ alignItems: 'center',
+ flex: 1,
},
- loadingText: {
- ...typography.bodySmall,
+ infoDescription: {
+ ...typography.caption,
color: colors.textSecondary,
+ textAlign: 'center',
+ },
+ infoGrid: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ marginBottom: spacing.lg,
+ marginHorizontal: spacing.lg,
},
- recentCard: {
- backgroundColor: colors.surfaceLight,
- marginTop: spacing.xl,
+ infoIcon: {
+ fontSize: 32,
+ marginBottom: spacing.sm,
},
- recentContent: {
- gap: spacing.sm,
+ infoTitle: {
+ ...typography.h4,
+ color: colors.text,
+ marginBottom: spacing.xs,
+ textAlign: 'center',
},
- recentDate: {
- ...typography.bodySmall,
- color: colors.textSecondary,
+ mainCard: {
+ backgroundColor: colors.surface,
+ marginBottom: spacing.lg,
+ marginHorizontal: spacing.lg,
+ },
+ primaryButton: {
+ backgroundColor: colors.primary,
+ borderRadius: borderRadius.lg,
+ elevation: 4,
+ minWidth: 200,
+ paddingHorizontal: spacing.xl,
+ paddingVertical: spacing.lg,
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ },
+ resultsCard: {
+ marginBottom: spacing.lg,
+ marginHorizontal: spacing.lg,
},
- recentSentiment: {
+ resultsHeader: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ marginBottom: spacing.lg,
+ },
+ resultsIcon: {
+ fontSize: 32,
+ marginRight: spacing.md,
+ },
+ resultsSubtitle: {
...typography.body,
- color: colors.text,
+ color: colors.textSecondary,
},
- recentTitle: {
+ resultsText: {
+ flex: 1,
+ },
+ resultsTitle: {
...typography.h3,
color: colors.text,
- marginBottom: spacing.md,
+ marginBottom: spacing.xs,
},
- retryButton: {
- marginTop: spacing.sm,
+ scrollContent: {
+ paddingBottom: spacing.xxl,
},
scrollView: {
flex: 1,
},
- startButton: {
- marginTop: spacing.lg,
+ secondaryButton: {
+ backgroundColor: colors.surface,
+ borderColor: colors.primary,
+ borderRadius: borderRadius.lg,
+ borderWidth: 2,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.md,
},
- statusCard: {
- marginBottom: spacing.lg,
+ secondaryButtonText: {
+ ...typography.body,
+ color: colors.primary,
+ fontWeight: '600',
},
- statusHeader: {
+ statItem: {
alignItems: 'center',
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: spacing.sm,
},
- statusText: {
- ...typography.body,
+ statLabel: {
+ ...typography.caption,
color: colors.textSecondary,
- marginBottom: spacing.sm,
+ marginTop: spacing.xs,
},
- statusTitle: {
+ statNumber: {
+ ...typography.h2,
+ color: colors.primary,
+ fontWeight: 'bold',
+ },
+ statsCard: {
+ marginBottom: spacing.lg,
+ marginHorizontal: spacing.lg,
+ },
+ statsContent: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+ statsTitle: {
...typography.h3,
color: colors.text,
+ marginBottom: spacing.md,
+ textAlign: 'center',
},
subtitle: {
...typography.body,
color: colors.textSecondary,
- lineHeight: 22,
- maxWidth: 280,
+ marginTop: spacing.sm,
textAlign: 'center',
},
- tipsCard: {
- backgroundColor: colors.surfaceLight,
- borderLeftColor: colors.info,
- borderLeftWidth: 4,
- marginTop: spacing.xl,
- },
- tipsText: {
- ...typography.body,
- color: colors.textSecondary,
- lineHeight: 22,
- },
- tipsTitle: {
- ...typography.h3,
- color: colors.text,
- marginBottom: spacing.md,
- },
title: {
...typography.h1,
- color: colors.primary,
- marginBottom: spacing.sm,
+ color: colors.text,
+ fontWeight: 'bold',
textAlign: 'center',
},
- viewDetailsButton: {
- alignSelf: 'flex-start',
- },
});