304 lines
9.8 KiB
TypeScript
304 lines
9.8 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
TextInput,
|
|
Dimensions,
|
|
ActivityIndicator,
|
|
Platform,
|
|
Animated,
|
|
Easing
|
|
} from 'react-native';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { MaterialCommunityIcons, Feather } from '@expo/vector-icons';
|
|
import { useAppTheme } from '../context/ThemeContext';
|
|
|
|
const { width } = Dimensions.get('window');
|
|
|
|
// ── AICard: Premium Clean Card ───────────────────────
|
|
export const AICard = ({ children, style, delay = 0, variant = 'white' }: any) => {
|
|
const { isDark, colors } = useAppTheme();
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
const slideAnim = useRef(new Animated.Value(30)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 500,
|
|
delay,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(slideAnim, {
|
|
toValue: 0,
|
|
duration: 500,
|
|
delay,
|
|
easing: Easing.out(Easing.back(1.5)),
|
|
useNativeDriver: true,
|
|
})
|
|
]).start();
|
|
}, [delay]);
|
|
|
|
let bg: string;
|
|
if (variant === 'white') bg = colors.surface;
|
|
else if (variant === 'lime') bg = colors.primary;
|
|
else if (variant === 'dark') bg = colors.secondary;
|
|
else bg = variant;
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
styles.card,
|
|
{
|
|
backgroundColor: bg,
|
|
borderColor: colors.border,
|
|
shadowColor: isDark ? '#000' : '#1A1A1A',
|
|
opacity: fadeAnim,
|
|
transform: [{ translateY: slideAnim }]
|
|
},
|
|
style
|
|
]}
|
|
>
|
|
{children}
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
// ── AIButton: High-End CTA Button ────────────────────
|
|
export const AIButton = ({ title, onPress, loading, icon, color, style, textStyle }: any) => {
|
|
const { isDark, colors } = useAppTheme();
|
|
|
|
const handlePress = () => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
if (onPress) onPress();
|
|
};
|
|
|
|
const defaultBg = colors.primary;
|
|
const defaultText = colors.secondary;
|
|
|
|
const btnBg = color || defaultBg;
|
|
const isLimeBg = btnBg === colors.primary;
|
|
const resolvedTextColor = textStyle?.color || (isLimeBg ? colors.secondary : defaultText);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={handlePress}
|
|
disabled={loading}
|
|
activeOpacity={0.82}
|
|
style={[
|
|
styles.button,
|
|
{ backgroundColor: btnBg },
|
|
style
|
|
]}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color={resolvedTextColor} />
|
|
) : (
|
|
<View style={styles.buttonContent}>
|
|
{icon && (
|
|
<MaterialCommunityIcons
|
|
name={icon}
|
|
size={20}
|
|
color={resolvedTextColor}
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
)}
|
|
<Text style={[styles.buttonText, { color: resolvedTextColor }, textStyle]}>{title}</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// ── AIInput: Themed Input Field ──────────────────────
|
|
export const AIInput = ({
|
|
label, icon, placeholder, value, onChangeText,
|
|
secure, isPassword, style, keyboardType, autoCapitalize = 'none'
|
|
}: any) => {
|
|
const { colors } = useAppTheme();
|
|
const [showPass, setShowPass] = React.useState(false);
|
|
const isSecure = secure || isPassword;
|
|
|
|
return (
|
|
<View style={[styles.inputGroup, style]}>
|
|
{label && (
|
|
<Text style={[styles.inputLabel, { color: colors.textSecondary }]}>{label}</Text>
|
|
)}
|
|
<View style={[styles.inputField, {
|
|
backgroundColor: colors.background,
|
|
borderColor: colors.border,
|
|
}]}>
|
|
{icon && (
|
|
<Feather
|
|
name={icon}
|
|
size={18}
|
|
color={colors.textPlaceholder}
|
|
style={{ marginRight: 12 }}
|
|
/>
|
|
)}
|
|
<TextInput
|
|
placeholder={placeholder}
|
|
placeholderTextColor={colors.textPlaceholder}
|
|
value={value}
|
|
onChangeText={onChangeText}
|
|
secureTextEntry={isSecure && !showPass}
|
|
style={[styles.textInput, { color: colors.text }]}
|
|
autoCapitalize={autoCapitalize}
|
|
keyboardType={keyboardType}
|
|
/>
|
|
{isSecure && (
|
|
<TouchableOpacity onPress={() => setShowPass(!showPass)} style={{ padding: 8 }}>
|
|
<Feather
|
|
name={showPass ? 'eye-off' : 'eye'}
|
|
size={18}
|
|
color={colors.textPlaceholder}
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// ── AISectionHeader: Section Title ───────────────────
|
|
export const AISectionHeader = ({ title, subtitle, action, onAction, style }: any) => {
|
|
const { colors } = useAppTheme();
|
|
return (
|
|
<View style={[styles.sectionHeader, style]}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>{title}</Text>
|
|
{subtitle && (
|
|
<Text style={[styles.sectionSub, { color: colors.textSecondary }]}>{subtitle}</Text>
|
|
)}
|
|
</View>
|
|
{action && (
|
|
<TouchableOpacity onPress={onAction}>
|
|
<Text style={[styles.sectionAction, { color: colors.primary }]}>{action}</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// ── AILimeBadge: Pill badge with lime accent ──────────
|
|
export const AILimeBadge = ({ label, style }: any) => {
|
|
const { colors } = useAppTheme();
|
|
return (
|
|
<View style={[styles.limeBadge, { backgroundColor: colors.primaryMuted }, style]}>
|
|
<Text style={[styles.limeBadgeText, { color: colors.primary }]}>{label}</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// ── AISkeleton: Premium Shimmer Loader ────────────────
|
|
export const AISkeleton = ({ width, height, radius = 12, style }: any) => {
|
|
const { isDark, colors } = useAppTheme();
|
|
const shimmerAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.loop(
|
|
Animated.timing(shimmerAnim, {
|
|
toValue: 1,
|
|
duration: 1500,
|
|
easing: Easing.linear,
|
|
useNativeDriver: true,
|
|
})
|
|
).start();
|
|
}, []);
|
|
|
|
const translateX = shimmerAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [-300, 300]
|
|
});
|
|
|
|
const bg = colors.surface;
|
|
const highlight = colors.surfaceElevated;
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
width: width || '100%',
|
|
height: height || 20,
|
|
borderRadius: radius,
|
|
backgroundColor: bg,
|
|
overflow: 'hidden',
|
|
},
|
|
style || {},
|
|
]}
|
|
>
|
|
<Animated.View style={[{ width: '100%', height: '100%', transform: [{ translateX }] }]}>
|
|
<LinearGradient
|
|
colors={[bg, highlight, bg]}
|
|
start={{ x: 0, y: 0.5 }}
|
|
end={{ x: 1, y: 0.5 }}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export const AIPressable = ({ children, onPress, style, containerStyle }: any) => {
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
const handlePressIn = () => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
Animated.spring(scale, { toValue: 0.97, damping: 10, stiffness: 300, useNativeDriver: true }).start();
|
|
};
|
|
const handlePressOut = () => {
|
|
Animated.spring(scale, { toValue: 1, damping: 10, stiffness: 300, useNativeDriver: true }).start();
|
|
};
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
activeOpacity={1}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
onPress={onPress}
|
|
style={style}
|
|
>
|
|
<Animated.View style={[containerStyle || { flex: 1 }, { transform: [{ scale }] }]}>
|
|
{children}
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// ── AISuccess: Animated Checkmark ──────────────────
|
|
export const AISuccess = ({ size = 80 }: { size?: number }) => {
|
|
const { colors } = useAppTheme();
|
|
const scale = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.spring(scale, { toValue: 1, damping: 12, stiffness: 200, useNativeDriver: true }).start();
|
|
}, []);
|
|
|
|
return (
|
|
<Animated.View style={[styles.successCircle, { width: size, height: size, backgroundColor: colors.primary + '20', transform: [{ scale }] }]}>
|
|
<Feather name="check" size={size * 0.6} color={colors.primary} />
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
card: { borderRadius: 24, borderWidth: 1, padding: 20, overflow: 'hidden', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.06, shadowRadius: 16, elevation: 3 },
|
|
button: { height: 58, borderRadius: 16, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 },
|
|
buttonContent: { flexDirection: 'row', alignItems: 'center' },
|
|
buttonText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
|
|
inputGroup: { marginBottom: 18 },
|
|
inputLabel: { fontSize: 12, fontFamily: 'Outfit_600SemiBold', marginBottom: 8, marginLeft: 2, textTransform: 'uppercase', letterSpacing: 0.5 },
|
|
inputField: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 14, borderWidth: 1, paddingHorizontal: 16 },
|
|
textInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
|
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginBottom: 14 },
|
|
sectionTitle: { fontSize: 20, fontFamily: 'Outfit_700Bold' },
|
|
sectionSub: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 2 },
|
|
sectionAction: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
|
limeBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
|
|
limeBadgeText: { fontSize: 11, fontFamily: 'Outfit_700Bold', textTransform: 'uppercase', letterSpacing: 0.5 },
|
|
successCircle: { borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
|
|
});
|